Ir al contenido
wannacode

El árbol de tecnologías

En resumen: Las tecnologías son nodos y los requisitos son flechas hacia ellas: un grafo dirigido sin ciclos. Una tecnología está libre cuando todos sus requisitos son conocidos; el orden topológico coloca todo de modo que cada una vaya después de sus requisitos, y un ciclo lo hace imposible.

Problema

Doce tecnologías hasta la Puerta Estelar

En Epic Cosmos la victoria por ciencia es la tecnología Puerta Estelar. No se investiga desde cero: necesita Acorazados y Rutas comerciales, los Acorazados necesitan Cañones de plasma y Nanoblindaje, y estos necesitan Fusión nuclear y más. El jugador se hace tres preguntas:

  1. ¿Qué puedo investigar ahora? No todo: una tecnología está bloqueada hasta que todos sus requisitos son conocidos.
  2. ¿En qué orden llego a la meta? Y cuánta ciencia costará el camino.
  3. ¿Puede la tabla estar mal? Si dos tecnologías se esperan una a otra, nada puede empezar.

Esta es la pantalla tech del juego (de la partida con la que se comprueba el juego). + es conocida, > se investiga ahora, * se puede investigar, . está bloqueada; la última línea cuenta el camino hasta la Puerta Estelar:

Turno 24/150 Imperio Terrano (A) puntos 93
M25+16 G173+30 C0+16 U47+3 ciencia +10
Investiga: Fortalezas orbitales 13/42
Tecnologías (+ conocida, > ahora, * posible, . bloqueada)
+ 1 drill Perforación profunda 17 -
+ 2 weapons1 Láseres 21 -
+ 3 hulls1 Blindaje ligero 21 -
+ 4 lab2 Laboratorios cuánticos 35 drill
* 5 fusion Fusión nuclear 49 lab2,weapons1
* 6 torpedo Torpedos 31 weapons1
* 7 cruiser Cascos de crucero 31 hulls1
* 8 trade Rutas comerciales 35 drill
> 9 fortress Fortalezas orbitales 42 hulls1,drill
. 10 weapons2 Cañones de plasma 71 fusion,torpedo
. 11 hulls2 Nanoblindaje 71 fusion,cruiser
. 12 dread Acorazados 107 weapons2,hulls2
. 13 stargate Puerta estelar 249 dread,trade
Hasta la Puerta Estelar: 8 tecnologías, 644 de ciencia, acumulad
Columnas: número, nombre, precio, requisitos.

La lección construye estas tres respuestas con programas pequeños sobre la misma tabla, y al final muestra lo mismo en el juego. Que los datos son una tabla de texto y no código se ve en la lección sobre las criaturas de Arcanum; aquí importa qué es la tabla como estructura.

La idea

Una tabla de requisitos

Una tecnología es una fila de una tabla: un número, un nombre, un precio en puntos de ciencia y requisitos, una lista de otros números. El juego guarda la tabla como texto y la lee al empezar, así que una tecnología nueva es una fila nueva, no código nuevo. Esta es la misma tabla tal como la cargan los programas de la lección (el juego tiene más columnas: los nombres en los otros idiomas, el efecto y su valor):

# id|nombre|precio|requisitos
drill|Perforación profunda|17|-
weapons1|Láseres|21|-
hulls1|Blindaje ligero|21|-
lab2|Laboratorios cuánticos|35|drill
fusion|Fusión nuclear|49|lab2,weapons1
torpedo|Torpedos|31|weapons1
cruiser|Cascos de crucero|31|hulls1
trade|Rutas comerciales|35|drill
fortress|Fortalezas orbitales|42|hulls1,drill
weapons2|Cañones de plasma|71|fusion,torpedo
hulls2|Nanoblindaje|71|fusion,cruiser
dread|Acorazados|107|weapons2,hulls2
stargate|Puerta estelar|249|dread,trade

Tres de las trece (drill, weapons1, hulls1) no tienen requisitos (-), y stargate necesita dos. Las filas con # son notas. Una fila se convierte en un objeto:

class Tech {
constructor(id, name, cost, prereqs) {
this.id = id;
this.name = name;
this.cost = cost;
this.prereqs = prereqs;
}
}
function load(text) {
const techs = new Map();
for (const line of text.split("\n")) {
if (line.trim() === "" || line.startsWith("#")) continue;
const c = line.split("|");
const prereqs = c[3] === "-" ? [] : c[3].split(",");
techs.set(c[0], new Tech(c[0], c[1], parseInt(c[2], 10), prereqs));
}
return techs;
}

load recorre las líneas, salta las vacías y las notas, corta por | y guarda las tecnologías en el orden de la tabla. El orden importa: por eso la colección es ordenada (LinkedHashMap en Java, Dictionary en C#, dict en Python, Map en JavaScript), para que los programas de los cuatro lenguajes impriman lo mismo.

Map en JavaScript conserva el orden de inserción por especificación.

La idea

Un grafo sin ciclos

Dibuja cada tecnología como un nodo y cada requisito como una flecha del requisito a la tecnología: drill → lab2, lab2 → fusion. La tabla se convierte en un grafo dirigido: las flechas tienen sentido. El nuestro tiene 13 nodos y 16 flechas.

El grafo de tecnologías tiene otra propiedad: no tiene ciclos. Si sigues las flechas, nunca vuelves al punto de partida. Un grafo así se llama grafo dirigido sin ciclos (DAG, directed acyclic graph) y es justo la forma de «primero esto, luego aquello»: tareas con dependencias, los módulos de un proyecto, el árbol de habilidades de un RPG.

Un ciclo es esperarse unos a otros. Digamos que optics necesita lasers, lasers necesita radar, radar necesita optics:

# id|nombre|precio|requisitos
optics|Óptica|10|lasers
lasers|Láseres|10|radar
radar|Radar|10|optics

Ninguna de las tres puede ser la primera: cada una espera a otra. El juego no debe arrancar con una tabla así, por eso comprueba dos veces. Primero al cargar: cada requisito debe ser una tecnología que existe.

for (const tech of TECHS.values()) {
for (const need of tech.prereqs) {
if (!TECHS.has(need)) throw new Error('techs: unknown prerequisite ' + need);
}
}

Después en main, antes de que empiece el juego: si el orden topológico (las tarjetas siguientes) no sale, el árbol tiene un ciclo.

if (topoOrder() === null) throw new Error('the tech tree has a cycle');

La idea

Qué se puede investigar ahora

El jugador conoce algunas tecnologías. Una tecnología está libre si aún no es conocida y todos sus requisitos son conocidos. El código dice lo mismo con un bucle y un retorno temprano:

function knows(known, techId) {
return known.includes(techId);
}
function canResearch(known, tech) {
if (knows(known, tech.id)) return false;
for (const need of tech.prereqs) {
if (!knows(known, need)) return false;
}
return true;
}
function availableTechs(techs, known) {
const list = [];
for (const tech of techs.values()) {
if (canResearch(known, tech)) list.push(tech);
}
return list;
}

canResearch devuelve false en cuanto ve un requisito desconocido, y si llega al final del bucle, true. Una tecnología sin requisitos tiene un bucle vacío, así que está libre desde el principio. availableTechs pregunta por cada tecnología de la tabla. El programa investiga drill, weapons1 y lab2 una por una y tras cada paso imprime qué se abre:

const techs = load(TABLE);
const known = [];
for (const learn of ["", "drill", "weapons1", "lab2"]) {
if (learn !== "") known.push(learn);
console.log("Conocidas: " + show(known));
console.log("Se puede investigar: " + show(idsOf(availableTechs(techs, known))));
}
Salida
Conocidas: -
Se puede investigar: drill, weapons1, hulls1
Conocidas: drill
Se puede investigar: weapons1, hulls1, lab2, trade
Conocidas: drill, weapons1
Se puede investigar: hulls1, lab2, torpedo, trade
Conocidas: drill, weapons1, lab2
Se puede investigar: hulls1, fusion, torpedo, trade

Fíjate en que fusion aparece solo al final: necesita lab2 y weapons1. Y drill desaparece de la lista en cuanto es conocida, porque canResearch empieza comprobando si ya la conoces.

function canResearch(known, tech) {
  if (knows(known, tech.id)) return false;
  for (const need of tech.prereqs) {
    if (!knows(known, need)) return ;
  }
  return true;
}

Estas son las mismas funciones del juego. El conocimiento es la lista empire.techs, no un argumento aparte, y todo lo demás es igual:

// Las tecnologías son un grafo dirigido sin ciclos: una arista del requisito a la tecnología. El conocimiento del
// imperio es una lista de ids; "qué se puede investigar ahora" son las desconocidas cuyos requisitos
// ya son conocidos.
function knows(empire, techId) {
return empire.techs.indexOf(techId) >= 0;
}
function canResearch(empire, tech) {
if (knows(empire, tech.id)) return false;
for (const need of tech.prereqs) {
if (!knows(empire, need)) return false;
}
return true;
}
function availableTechs(empire) {
const list = [];
for (const tech of TECHS.values()) {
if (canResearch(empire, tech)) list.push(tech);
}
return list;
}
Más precisamente, para curiosos

Las funciones listas de ahí trabajan en dos niveles: canResearch responde por una tecnología, availableTechs reúne las respuestas de todas. Así el mismo canResearch sirve a startResearch (si la investigación puede empezar) y a la inteligencia artificial que elige entre las libres (la lección sobre los rivales).

La idea

Orden topológico

¿Qué orden de investigación está permitido? Uno en el que cada tecnología va después de sus requisitos. Se llama orden topológico, y todo grafo sin ciclos tiene al menos uno. El algoritmo es simple: mientras queden sin colocar, toma la primera tecnología cuyos requisitos ya están todos colocados, dale el número siguiente y empieza de nuevo.

function topoOrder(techs) {
const placed = [];
const done = new Set();
while (placed.length < techs.size) {
let pick = null;
for (const tech of techs.values()) {
if (done.has(tech.id)) continue;
let ready = true;
for (const need of tech.prereqs) {
if (!done.has(need)) ready = false;
}
if (ready) {
pick = tech;
break;
}
}
if (pick === null) return null; // ciclo: ninguna tecnología lista
placed.push(pick.id);
done.add(pick.id);
}
return placed;
}

Si en algún paso no hay tal tecnología y aún quedan sin colocar, se esperan unas a otras: la función devuelve null. Así el mismo algoritmo es también una comprobación de ciclos.

El programa pone la tabla al revés (stargate va primero), para mostrar que el orden no viene de ella, y luego prueba la tabla con el ciclo:

const techs = load(REVERSED); // la tabla está al revés, pero el orden no depende de ella
const order = topoOrder(techs);
for (let i = 0; i < order.length; i++) {
console.log((i + 1) + " " + order[i]);
}
if (topoOrder(load(CYCLE)) === null) console.log("Ciclo: ninguna tecnología tiene listos todos sus requisitos.");
Salida
1 hulls1
2 cruiser
3 weapons1
4 torpedo
5 drill
6 fortress
7 trade
8 lab2
9 fusion
10 hulls2
11 weapons2
12 dread
13 stargate
Ciclo: ninguna tecnología tiene listos todos sus requisitos.

Primero sale hulls1: todas las de delante esperan algo. Después cruiser, porque hulls1 ya está colocada. Cada flecha del árbol apunta de un número menor a uno mayor, y la tabla con el ciclo devuelve null. Cuando hay varias listas, gana la primera de la tabla, así que el orden es el mismo en todas partes. Esa es también la regla del juego:

// Orden topológico (Kahn): una tecnología sale cuando sus requisitos ya han salido. Si hay
// varias listas, gana la de número menor en la tabla, por eso el orden es el mismo en todas partes.
function topoOrder() {
const placed = [];
const done = new Set();
const total = TECHS.size;
while (placed.length < total) {
let pick = null;
for (const tech of TECHS.values()) {
if (done.has(tech.id)) continue;
let ready = true;
for (const need of tech.prereqs) {
if (!done.has(need)) ready = false;
}
if (ready) {
pick = tech;
break;
}
}
// Un ciclo: ninguna tecnología tiene sus requisitos listos.
if (pick === null) return null;
placed.push(pick.id);
done.add(pick.id);
}
return placed;
}

La idea

Un plan hasta la meta

El jugador elige una meta, por ejemplo stargate, y quiere una lista: qué falta por investigar y en qué orden. Dos pasos.

  1. Qué hace falta. Empezamos en la meta y vamos hacia atrás por las flechas con una pila: sacamos una tecnología, y si no es conocida, la anotamos en need y metemos sus requisitos en la pila. Las conocidas se saltan, así que la parte ya investigada no se paga otra vez.
  2. En qué orden. need es un conjunto, sin orden. El orden viene del topológico: pasamos por topoOrder y nos quedamos solo con las necesarias.
function planTo(techs, known, targetId) {
const need = new Set();
const stack = [targetId];
while (stack.length > 0) {
const id = stack.pop();
if (need.has(id) || knows(known, id)) continue;
need.add(id);
for (const pre of techs.get(id).prereqs) stack.push(pre);
}
const plan = [];
for (const id of topoOrder(techs)) {
if (need.has(id)) plan.push(id);
}
return plan;
}
function planCost(techs, plan) {
let sum = 0;
for (const id of plan) sum += techs.get(id).cost;
return sum;
}

El programa planea el camino hasta la Puerta Estelar sin conocimientos y luego con drill, weapons1, hulls1 y lab2 conocidas:

const techs = load(TABLE);
for (const known of [[], ["drill", "weapons1", "hulls1", "lab2"]]) {
const plan = planTo(techs, known, "stargate");
console.log("Conocidas: " + show(known));
console.log("Plan: " + show(plan));
console.log(plan.length + " tecnologías, " + planCost(techs, plan) + " de ciencia");
}
Salida
Conocidas: -
Plan: drill, weapons1, hulls1, lab2, fusion, torpedo, cruiser, trade, weapons2, hulls2, dread, stargate
12 tecnologías, 738 de ciencia
Conocidas: drill, weapons1, hulls1, lab2
Plan: fusion, torpedo, cruiser, trade, weapons2, hulls2, dread, stargate
8 tecnologías, 644 de ciencia

El plan sin conocimientos tiene 12 tecnologías, no 13: nadie en el camino necesita fortress. La suma 738 es lo que la pantalla del principio de la lección muestra como «Hasta la Puerta Estelar», y con cuatro conocidas quedan 8 tecnologías por 644. En el canon es igual:

// Lo que aún hay que investigar para llegar a la meta, en orden topológico.
function planTo(empire, targetId) {
const need = new Set();
const stack = [targetId];
while (stack.length > 0) {
const id = stack.pop();
if (need.has(id) || knows(empire, id)) continue;
need.add(id);
for (const pre of TECHS.get(id).prereqs) stack.push(pre);
}
const plan = [];
for (const id of topoOrder()) {
if (need.has(id)) plan.push(id);
}
return plan;
}
function planCost(plan) {
let sum = 0;
for (const id of plan) sum += TECHS.get(id).cost;
return sum;
}

Míralo

El árbol en movimiento

La escena recorre las tres respuestas: qué se abre tras cada tecnología investigada, el orden para la tabla al revés y el recorrido hacia atrás desde la Puerta Estelar. Sigue el color de los marcos (verde es libre, azul es conocida, naranja es necesaria) y los números de las insignias:

function planTo(techs, known, targetId) {
const need = new Set();
const stack = [targetId];
while (stack.length > 0) {
const id = stack.pop();
if (need.has(id) || knows(known, id)) continue;
need.add(id);
for (const pre of techs.get(id).prereqs) stack.push(pre);
}
const plan = [];
for (const id of topoOrder(techs)) {
if (need.has(id)) plan.push(id);
}
return plan;
}
Paso a paso, en palabras
  1. Trece tecnologías. Una flecha va del requisito a la tecnología: lab2 necesita drill, fusion necesita lab2 y weapons1. El número bajo el nombre es el precio.
  2. Nada está investigado. drill, weapons1 y hulls1 no tienen requisitos: el bucle no encuentra ninguno desconocido y canResearch devuelve true. Solo ellas están libres (verde).
  3. Investigas drill (azul). Ahora lab2 y trade necesitan solo drill y están libres. fortress necesita también hulls1, así que espera. drill ya no está en la lista: ya es conocida.
  4. Investigas weapons1: torpedo se desbloquea. fusion necesita lab2 y weapons1: weapons1 ya es conocida, lab2 todavía no, así que el bucle devuelve false en lab2.
  5. Investigas lab2: ahora fusion tiene los dos requisitos y está libre. Un requisito que falta mantiene bloqueada la tecnología; todos listos la abren.
  6. Otra pregunta: ¿en qué orden investigamos todo? La tabla está al revés (stargate va primero), pero el orden no sale de ella. topoOrder recorre la tabla y toma la primera tecnología cuyos requisitos están todos colocados.
  7. stargate, dread y las demás de delante esperan requisitos. La primera lista es hulls1 (sin requisitos): recibe el número 1.
  8. Tras cada número la búsqueda empieza de nuevo: ahora cruiser está lista, porque hulls1 ya está colocada. Las colocadas se saltan.
  9. La última es stargate, número 13: esperaba a dread y trade. Cada flecha apunta de un número menor a uno mayor: ese es el orden topológico. Si en algún paso no hay ninguna tecnología lista, hay un ciclo y la función devuelve null.
  10. Un plan hasta stargate. Empezamos en la meta y vamos hacia atrás por las flechas con una pila: sacamos una tecnología y metemos sus requisitos. Todo lo alcanzado entra en need (naranja).
  11. fortress nunca se alcanzó: ninguna tecnología en el camino hasta stargate la necesita. No entra en el plan. Las conocidas se saltan, así que lo ya investigado no se paga otra vez.
  12. El recorrido solo dice qué hace falta. El orden viene del topológico: planTo pasa por topoOrder y se queda con las necesarias. Así el plan tiene 12 tecnologías en un orden en que cada una va después de sus requisitos.
  13. 12 tecnologías, 738 de ciencia: la suma de los precios. fortress (42) no hace falta. El juego muestra el mismo número en la pantalla tech como «Hasta la Puerta Estelar».

El grafo es el mismo en las tres partes. Solo cambia la pregunta que se le hace: qué está libre (una mirada hacia delante desde las conocidas), en qué orden (colocar todo) y hasta dónde (una mirada hacia atrás desde la meta).

Pruébalo

Predice la salida

Una tabla pequeña: sensors no tiene requisitos, engines y shields necesitan sensors, warp necesita las dos, beacon necesita warp. Son conocidas sensors y engines. ¿Qué imprime el programa?

const SMALL = `
# id|nombre|precio|requisitos
sensors|Sensores|10|-
engines|Motores|12|sensors
shields|Escudos|12|sensors
warp|Salto|20|engines,shields
beacon|Baliza|25|warp
`;
const techs = load(SMALL);
const known = ["sensors", "engines"];
console.log(show(idsOf(availableTechs(techs, known))));
Respuesta

Pruébalo

Encuentra el error

Un compañero copió canResearch y puso el nombre equivocado en la comprobación de los requisitos. Esto es lo que imprime el programa con solo drill conocida; si fuera correcto, aquí estarían también lab2 y trade:

function canResearch(known, tech) {
if (knows(known, tech.id)) return false;
for (const need of tech.prereqs) {
if (!knows(known, tech.id)) return false;
}
return true;
}
const techs = load(TABLE);
const known = ["drill"];
console.log("Conocidas: " + show(known));
console.log("Se puede investigar: " + show(idsOf(availableTechs(techs, known))));
Salida
Conocidas: drill
Se puede investigar: weapons1, hulls1

¿Qué línea tiene la culpa?

Elige la línea con el error.

Más precisamente

Lo que este sistema deja fuera

El árbol del juego tiene pocas reglas, y detrás de ellas hay decisiones.

Todos los requisitos, no «alguno de». canResearch devuelve false en el primer desconocido. Algunos juegos permiten «esto o aquello»: entonces un requisito es un grupo, y la tecnología se abre cuando se cumple uno de los grupos. Eso es otro grafo (con dos clases de flechas), no otra función.

El orden en un empate forma parte de la regla. Muchas tecnologías pueden estar listas a la vez, y cualquier disposición es correcta. El canon toma la primera de la tabla, para que salga el mismo orden en todos los lenguajes y en cada ejecución. Si el orden dependiera del orden de inserción de una tabla hash, los programas de dos lenguajes podrían diferir.

El algoritmo topológico de aquí es el más simple, no el más rápido. topoOrder recorre la tabla desde el principio para cada número: con n tecnologías son unas n² comprobaciones. Con trece no es nada. Con miles (dependencias de paquetes) se usa el algoritmo de Kahn, con un contador de requisitos pendientes por tecnología y una cola con las listas: O(V + E). Esa es la lección sobre la ordenación topológica, y recorrer un grafo con una pila está en la lección de DFS.

El efecto de una tecnología es otro trabajo. Aquí una tecnología es solo un nodo con un precio. Lo que da (más producción, ataque, una nave nueva) está en otras columnas de la tabla, y techMods suma los efectos de todas las conocidas; qué nave necesita qué está en la lección sobre las flotas.

El ciclo se detecta al empezar, no durante el juego. La tabla es parte del código del juego, no una entrada del jugador. Por eso la comprobación es una llamada a topoOrder en main. Si la tabla viniera de un usuario (un mod), la misma comprobación sería un mensaje de error para él, no una caída del juego.

Ejercicio

Un plan para una tabla dada

Resuélvelo en tu editor.

Un plan para una tabla dada. El programa lee tres cosas de la entrada: en la primera línea la meta, en la segunda las tecnologías conocidas separadas por comas (o - si no hay), y en cada línea siguiente una tecnología con la forma id precio requisitos (los requisitos separados por comas, - si no hay). La tabla está en orden topológico, pero el programa no debe apoyarse en eso. Imprime el plan (solo las tecnologías necesarias sin colocar, en orden topológico) y la suma de los precios.

Con la entrada warp | sensors | sensors 10 - | engines 12 sensors | shields 12 sensors | warp 20 engines,shields | beacon 25 warp (una por línea) la salida es:

Salida
Plan: engines, shields, warp
Precio: 44

Ojo: sensors es conocida, así que no está en el plan y no se paga; warp es la meta y va la última del plan.

Más ejercicios

  • Un ciclo en la tabla. Da una tabla en la que a necesita b y b necesita a. ¿Qué hace planTo con un topoOrder que devuelve null? Haz que el programa diga que hay un ciclo en vez de caerse.
  • Un requisito desconocido. Añade una fila con un requisito que no está en la tabla. Atrápalo al cargar, como lo hace loadData.
  • ¿Qué desbloquea esta? Escribe unlocks(techs, id), que devuelve las tecnologías que necesitan id como requisito. Es un recorrido hacia delante por las flechas.
  • La primera jugada más barata. Entre las tecnologías libres elige la de menor precio. ¿Qué pasa si dos cuestan lo mismo?

Test

Ponte a prueba

1. ¿Por qué la tabla de tecnologías no debe tener un ciclo, por ejemplo optics necesita lasers, lasers necesita radar, radar necesita optics?
2. fusion necesita lab2 y weapons1. Son conocidas drill y weapons1. ¿Se puede investigar fusion?
3. En el programa topo la tabla está al revés (stargate va primero). ¿Por qué topoOrder da igualmente un orden correcto?
4. ¿Por qué fortress no entra en el plan hasta stargate?
5. topoOrder devolvió null. ¿Qué significa para la tabla?

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