diff --git a/lib/duel.js b/lib/duel.js new file mode 100644 index 0000000..052f081 --- /dev/null +++ b/lib/duel.js @@ -0,0 +1,204 @@ +'use strict' + +/** + * runa: una sesion de duelo. + * + * El Coliseo ya existe como mapa propio (`lib/coliseum.js`) y publica todo lo + * que hace falta para pararse adentro: `duelSpawns`, `arenaBounds` y + * `refereeSpawn`. Este archivo no dibuja nada. Se ocupa de lo otro: quien va de + * que lado, donde vuelve cada uno cuando termina, y que nadie se escape a las + * gradas mientras la pelea esta viva. + * + * Tres decisiones que explican el resto. + * + * 1. **Los lados se calculan, no se acuerdan.** Dos jugadores sin servidor + * tienen que llegar al mismo reparto por su cuenta, y cualquier negociacion + * ("vos oeste, yo este") es un mensaje que se puede perder o contradecir. + * Comparar las dos identidades y que la menor sea oeste no necesita ningun + * mensaje: los dos hacen la misma cuenta y les da lo mismo. + * + * 2. **La vuelta se guarda antes de salir.** Un duelo puede terminar porque + * alguien gano, porque se rindio, o porque se le corto internet. El ultimo + * caso es el que manda el diseno: si el regreso dependiera de un mensaje de + * cierre, el que se desconecta quedaria varado en el Coliseo para siempre. + * Por eso `from` se guarda al entrar y alcanza con tenerlo para volver. + * + * 3. **Este modulo no sabe de daño.** No calcula quien gana, no toca la vida + * de nadie y no habla con la cadena. `docs/coliseum.md` pide exactamente + * eso del mapa, y vale igual para la sesion: geometria y estado, nada mas. + * Los duelos, el jefe mundial y los encuentros con monstruos son sesiones + * distintas y mezclarles el estado es el error que hay que no cometer. + */ + +/** Los estados posibles. Un duelo no vuelve de `over`. */ +const IDLE = 'idle' +const ACTIVE = 'active' +const OVER = 'over' + +/** + * De que lado le toca a cada uno. + * + * Se comparan las dos identidades como texto y la menor va al oeste. No importa + * cual criterio se elija mientras sea total y los dos usen el mismo; lo que + * importa es que **no haga falta preguntar**. Los dos jugadores corren esta + * funcion con los mismos dos nombres, en el orden que sea, y les da lo mismo. + * + * @param {string} self + * @param {string} rival + * @returns {'west'|'east'} + */ +function sideFor(self, rival) { + const a = String(self) + const b = String(rival) + if (a === b) { + // Dos identidades iguales no son dos jugadores. Se devuelve algo estable en + // vez de tirar, porque quien llame a esto puede estar dibujando un cuadro. + return 'west' + } + return a < b ? 'west' : 'east' +} + +/** El lado contrario. */ +function otherSide(side) { + return side === 'west' ? 'east' : 'west' +} + +class Duel { + /** + * @param {object} opts + * @param {object} opts.arena - `MAPS.coliseum`. De aca salen las coordenadas; + * este modulo no tiene ninguna escrita, para que mover el arte del Coliseo + * no obligue a tocar la logica de duelos. + * @param {string} opts.self - identidad del jugador local + * @param {string} opts.rival - identidad del rival + */ + constructor({ arena, self, rival } = {}) { + if (!arena || !Array.isArray(arena.duelSpawns) || arena.duelSpawns.length < 2) { + throw new Error('el duelo necesita un mapa con dos duelSpawns') + } + if (!arena.arenaBounds) { + throw new Error('el duelo necesita arenaBounds para encerrar a los que pelean') + } + + this.arena = arena + this.self = String(self) + this.rival = String(rival) + this.side = sideFor(this.self, this.rival) + this.state = IDLE + + /** Donde estaba el jugador antes de entrar. Se llena en `begin`. */ + this.from = null + /** Por que termino. Lo lee la interfaz para decir algo. */ + this.reason = null + } + + /** ¿Hay una pelea en curso? */ + get active() { + return this.state === ACTIVE + } + + /** + * El punto donde le toca pararse a un lado. + * + * Sale de `arena.duelSpawns` y se devuelve copiado. Devolver el objeto del + * mapa dejaria que quien lo reciba le mueva las coordenadas al Coliseo sin + * querer, y el proximo duelo empezaria torcido. + * + * @param {'west'|'east'} [side] - por omision, el lado del jugador local + */ + spawnFor(side = this.side) { + const found = this.arena.duelSpawns.find((s) => s.id === side) + if (!found) throw new Error('el mapa no publica el lado ' + side) + return { ...found } + } + + /** Donde se para el rival. */ + rivalSpawn() { + return this.spawnFor(otherSide(this.side)) + } + + /** + * Donde se para el arbitro, si el protocolo llega a necesitar uno visible. + * + * Hoy nadie lo usa, y esta igual porque el mapa lo reserva: si el dia de + * manana la autoridad tiene cuerpo, el lugar ya esta y no hay que inventarlo + * en medio de otra cosa. + */ + refereeSpawn() { + return this.arena.refereeSpawn ? { ...this.arena.refereeSpawn } : null + } + + /** + * Entrar al Coliseo. + * + * @param {{mapId: string, x: number, y: number}} from - donde estaba el + * jugador. Se guarda tal cual y es lo unico que hace falta para volver. + * @returns {{mapId: 'coliseum', x: number, y: number, facing: string}} + */ + begin(from) { + if (this.state === ACTIVE) throw new Error('el duelo ya empezo') + if (this.state === OVER) throw new Error('este duelo ya termino') + if (!from || typeof from.mapId !== 'string') { + throw new Error('hace falta saber de donde vino para poder devolverlo') + } + + this.from = { mapId: from.mapId, x: from.x, y: from.y } + this.state = ACTIVE + + const spawn = this.spawnFor() + return { mapId: 'coliseum', x: spawn.x, y: spawn.y, facing: spawn.facing } + } + + /** + * Encerrar una posicion dentro del campo. + * + * `docs/coliseum.md` lo pide con estas palabras: los limites existen "para + * impedir que un combatiente huya a las gradas". Se recorta en vez de + * rechazar el movimiento porque el que camina contra el borde tiene que + * quedar pegado al borde, no rebotar ni quedarse trabado. + */ + clamp(x, y) { + const b = this.arena.arenaBounds + return { + x: Math.min(Math.max(x, b.x1), b.x2), + y: Math.min(Math.max(y, b.y1), b.y2) + } + } + + /** ¿Esta posicion quedo afuera del campo? */ + inside(x, y) { + const b = this.arena.arenaBounds + return x >= b.x1 && x <= b.x2 && y >= b.y1 && y <= b.y2 + } + + /** + * ¿Hay que bloquear la salida `Q`? + * + * La baldosa `Q` vuelve a la ciudad y existe como salida de seguridad. Con un + * duelo vivo se bloquea, porque si no el que va perdiendo se va caminando y + * el duelo no termina nunca ni gana nadie. + */ + blocksExit() { + return this.active + } + + /** + * Terminar. + * + * Sirve para las tres formas de terminar y a proposito no las distingue en el + * regreso: el que gana, el que se rinde y el que se queda sin internet + * vuelven todos al mismo lugar del que salieron. La diferencia entre esos + * casos es de puntaje y de premio, y eso no vive aca. + * + * @param {string} [reason] + * @returns {{mapId: string, x: number, y: number}|null} donde devolverlo + */ + end(reason = 'termino') { + if (this.state === OVER) return this.from ? { ...this.from } : null + this.state = OVER + this.reason = reason + return this.from ? { ...this.from } : null + } +} + +module.exports = { Duel, sideFor, otherSide, IDLE, ACTIVE, OVER } diff --git a/test/duel.test.js b/test/duel.test.js new file mode 100644 index 0000000..8a30e65 --- /dev/null +++ b/test/duel.test.js @@ -0,0 +1,173 @@ +const { test } = require('brittle') +const { MAPS } = require('../lib/map.js') +const { Duel, sideFor, otherSide } = require('../lib/duel.js') + +const arena = MAPS.coliseum + +function nuevo(self = 'ana', rival = 'beto') { + return new Duel({ arena, self, rival }) +} + +test('los lados se calculan igual desde las dos puntas', (t) => { + // Esto es lo que evita un mensaje de coordinacion. Los dos jugadores hacen la + // cuenta con los mismos dos nombres y les tiene que dar lados opuestos, sin + // haberse puesto de acuerdo en nada. + const desdeAna = sideFor('ana', 'beto') + const desdeBeto = sideFor('beto', 'ana') + t.is(desdeAna, 'west') + t.is(desdeBeto, 'east') + t.is(otherSide(desdeAna), desdeBeto, 'nunca pueden caer del mismo lado') +}) + +test('el reparto de lados no depende del orden en que se pregunte', (t) => { + const nombres = ['zoe', 'ana', 'beto', 'carlos', 'diana', 'ur', 'A', 'a', '0'] + for (const a of nombres) { + for (const b of nombres) { + if (a === b) continue + t.is(otherSide(sideFor(a, b)), sideFor(b, a), a + ' vs ' + b) + } + } +}) + +test('las coordenadas salen del mapa, no de este modulo', (t) => { + // La nota de coordinacion lo pide asi: sin copiar numeros a otro modulo. Si + // Codex mueve el arte del Coliseo, el duelo tiene que seguirlo solo. + const d = nuevo() + const oeste = arena.duelSpawns.find((s) => s.id === 'west') + t.alike(d.spawnFor('west'), { ...oeste }) + t.is(d.spawnFor('west').x, oeste.x) + t.is(d.spawnFor('west').y, oeste.y) +}) + +test('el punto que devuelve es una copia, no el del mapa', (t) => { + // Devolver el objeto original dejaria que quien lo reciba le mueva las + // coordenadas al Coliseo sin querer, y el proximo duelo empezaria torcido. + const d = nuevo() + const antes = arena.duelSpawns.find((s) => s.id === 'west').x + const copia = d.spawnFor('west') + copia.x = 999 + t.is(arena.duelSpawns.find((s) => s.id === 'west').x, antes, 'el mapa quedo intacto') +}) + +test('los dos miran hacia el centro', (t) => { + const d = nuevo() + t.is(d.spawnFor('west').facing, 'east') + t.is(d.spawnFor('east').facing, 'west') +}) + +test('entrar guarda de donde vino', (t) => { + const d = nuevo() + const donde = d.begin({ mapId: 'city', x: 12, y: 34 }) + t.is(donde.mapId, 'coliseum') + t.is(donde.x, d.spawnFor().x) + t.ok(d.active) + t.alike(d.from, { mapId: 'city', x: 12, y: 34 }) +}) + +test('no se entra sin decir de donde', (t) => { + // Sin `from` no habria como devolverlo, y el jugador quedaria varado en el + // Coliseo. Es mejor negarse a empezar que empezar algo sin salida. + const d = nuevo() + t.exception(() => d.begin(), /de donde vino/) + t.exception(() => d.begin({ x: 1, y: 2 }), /de donde vino/) + t.absent(d.active) +}) + +test('terminar devuelve al lugar exacto del que salio', (t) => { + const d = nuevo() + d.begin({ mapId: 'city', x: 12, y: 34 }) + const vuelta = d.end('gano ana') + t.alike(vuelta, { mapId: 'city', x: 12, y: 34 }) + t.absent(d.active) + t.is(d.reason, 'gano ana') +}) + +test('rendirse y desconectarse vuelven al mismo lugar que ganar', (t) => { + // La diferencia entre esos tres casos es de puntaje y de premio, no de + // geografia. El que se queda sin internet no puede quedar preso del Coliseo. + const destinos = ['gano', 'se rindio', 'se desconecto'].map((motivo) => { + const d = nuevo() + d.begin({ mapId: 'field', x: 7, y: 8 }) + return d.end(motivo) + }) + t.alike(destinos[0], destinos[1]) + t.alike(destinos[1], destinos[2]) +}) + +test('terminar dos veces no rompe ni cambia el destino', (t) => { + // Puede llegar el aviso de que el rival se fue justo despues de que el duelo + // ya termino por otra via. La segunda vez tiene que ser inofensiva. + const d = nuevo() + d.begin({ mapId: 'city', x: 5, y: 6 }) + const primera = d.end('gano') + const segunda = d.end('se desconecto') + t.alike(segunda, primera) + t.is(d.reason, 'gano', 'el primer motivo es el que vale') +}) + +test('la salida Q se bloquea solo mientras el duelo vive', (t) => { + const d = nuevo() + t.absent(d.blocksExit(), 'antes de empezar se puede salir') + d.begin({ mapId: 'city', x: 1, y: 1 }) + t.ok(d.blocksExit(), 'con la pelea viva, no') + d.end() + t.absent(d.blocksExit(), 'al terminar se libera') +}) + +test('nadie se escapa a las gradas', (t) => { + const d = nuevo() + const b = arena.arenaBounds + t.alike(d.clamp(b.x1 - 40, b.y1 - 40), { x: b.x1, y: b.y1 }) + t.alike(d.clamp(b.x2 + 40, b.y2 + 40), { x: b.x2, y: b.y2 }) + t.alike(d.clamp(b.center.x, b.center.y), { x: b.center.x, y: b.center.y }, 'el centro no se toca') + t.ok(d.inside(b.center.x, b.center.y)) + t.absent(d.inside(b.x1 - 1, b.center.y)) + t.absent(d.inside(b.center.x, b.y2 + 1)) +}) + +test('los dos puntos de salida caen adentro del campo', (t) => { + // Si el arte del Coliseo se moviera y un spawn quedara fuera de arenaBounds, + // el jugador apareceria ya empujado contra un borde. Este test lo caza. + const d = nuevo() + for (const s of arena.duelSpawns) { + t.ok(d.inside(s.x, s.y), 'el lado ' + s.id + ' esta dentro del campo') + } +}) + +test('los dos lados son simetricos respecto del centro', (t) => { + // Se mide contra el centro geometrico del campo y no contra + // `arenaBounds.center.x`, que viene redondeado. El Coliseo mide 128 de ancho, + // asi que su centro real cae en 63.5: contra 64 los dos lados darian 24 y 23 y + // pareceria que el mapa esta torcido cuando no lo esta. + const b = arena.arenaBounds + const centro = (b.x1 + b.x2) / 2 + const oeste = arena.duelSpawns.find((s) => s.id === 'west') + const este = arena.duelSpawns.find((s) => s.id === 'east') + t.is(oeste.y, este.y, 'a la misma altura') + t.is(centro - oeste.x, este.x - centro, 'a la misma distancia del centro') +}) + +test('el lugar del arbitro esta reservado y es una copia', (t) => { + const d = nuevo() + const r = d.refereeSpawn() + t.ok(r, 'el mapa lo publica') + t.ok(d.inside(r.x, r.y), 'y cae dentro del campo') + r.x = 999 + t.not(arena.refereeSpawn.x, 999) +}) + +test('un duelo necesita un mapa que publique los puntos', (t) => { + t.exception(() => new Duel({ arena: {}, self: 'a', rival: 'b' }), /duelSpawns/) + t.exception( + () => new Duel({ arena: { duelSpawns: [1, 2] }, self: 'a', rival: 'b' }), + /arenaBounds/ + ) +}) + +test('un duelo no se puede empezar dos veces', (t) => { + const d = nuevo() + d.begin({ mapId: 'city', x: 1, y: 1 }) + t.exception(() => d.begin({ mapId: 'city', x: 2, y: 2 }), /ya empezo/) + d.end() + t.exception(() => d.begin({ mapId: 'city', x: 3, y: 3 }), /ya termino/) +}) diff --git a/test/index.js b/test/index.js index 7be3ead..7c08027 100644 --- a/test/index.js +++ b/test/index.js @@ -19,6 +19,7 @@ const { const render = require('../lib/render.js') require('./sage.test.js') +require('./duel.test.js') function press(game, name) { return game.onKey({ type: 'key', is: (...keys) => keys.includes(name) })