Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
204 changes: 204 additions & 0 deletions lib/duel.js
Original file line number Diff line number Diff line change
@@ -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 }
Comment on lines +141 to +145

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: begin() accepts from without numeric x/y

begin() only validates that from.mapId is a string, not that x/y are numbers. A caller passing {mapId:'city'} (no coords) is accepted, and this.from stores {x:undefined,y:undefined}; end() then returns those undefined coordinates and the player is sent to an invalid position instead of their origin — the exact 'varado' outcome the design tries to avoid. Validate that from.x and from.y are finite numbers alongside the mapId check.

Was this helpful? React with 👍 / 👎

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
}
Comment on lines +196 to +201

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Edge Case: end() on an idle duel silently moves it to OVER

Calling end() before begin() (state IDLE) falls through, sets state=OVER and returns null. A subsequent begin() then throws 'este duelo ya termino', so a duel can be permanently killed before it ever started. Consider guarding end() to only act when state===ACTIVE (return null/no-op when IDLE), so a stray close event before entry can't brick the session.

Was this helpful? React with 👍 / 👎

}

module.exports = { Duel, sideFor, otherSide, IDLE, ACTIVE, OVER }
173 changes: 173 additions & 0 deletions test/duel.test.js
Original file line number Diff line number Diff line change
@@ -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/)
})
1 change: 1 addition & 0 deletions test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) })
Expand Down