diff --git a/lib/game.js b/lib/game.js index d781969..79e719f 100644 --- a/lib/game.js +++ b/lib/game.js @@ -29,6 +29,7 @@ const { Field, Y_SCALE } = require('./field.js') const render = require('./render.js') const { parse } = require('./script.js') const portraits = require('./portraits.js') +const { SageSession } = require('./sage-npc.js') const { ARENA } = require('./world.js') const CONTENT = require('./content.js') @@ -55,6 +56,8 @@ const COMBAT_TURN_TICKS = 6 /** Where the player's strategy lives. */ const SCRIPT_PATH = 'script.txt' +/** Sage input cap: mirrors the translator's own limit (issue #11). */ +const SAGE_MAX_INPUT = 120 const DEFAULT_SCRIPT = [ '// tu estrategia. se relee sola mientras peleas.', @@ -222,6 +225,10 @@ class Runa { this.scriptSource = '' this.scriptMtime = 0 this.scriptErrors = [] + /** Sage conversation state (issue #11). */ + this.sageSession = null + this.sageBuffer = '' + this.legendOpen = false // Presence is built here and started in init(). A constructor that opens a // socket is a class that cannot be built in a test, and every test in this @@ -264,6 +271,52 @@ class Runa { * @param {boolean} [force] * @returns {boolean} whether it reloaded */ + /** Open the sage conversation (used by the NPC action and tests). */ + startSage() { + this.sageSession = new SageSession( + null, + () => { + try { return fs.readFileSync(SCRIPT_PATH, 'utf8') } catch { return '' } + }, + (next) => fs.writeFileSync(SCRIPT_PATH, next), + (lines) => { + for (const line of lines || []) this.say(line) + if (this.activeSlot) this.loadScript(true) + this.sageSession = null + } + ).start() + for (const line of this.sageSession.say || []) this.say(line) + return this.sageSession + } + + /** + * Sage mode collects one sentence via the shared text input. Keys arrive + * here from the main handler while `sageSession` is active; enter submits, + * escape closes (handled by the caller). + * @param {object} msg + * @returns {string|null} same contract as other handlers: null keeps playing + */ + sageKey(msg) { + const ch = typeof msg === 'string' ? msg : (msg && msg.key) || '' + if (!ch) return null + if (ch === 'enter' || ch === 'return') { + const sentence = this.sageBuffer.trim() + this.sageBuffer = '' + if (sentence) this.sageSession.ask(sentence) + else this.sageSession.close('el sabio espera una frase') + return null + } + if (ch === 'backspace' || ch === 'delete') { + this.sageBuffer = this.sageBuffer.slice(0, -1) + return render.sagePrompt(this.sageBuffer) + } + if (ch.length === 1) { + if (this.sageBuffer.length < SAGE_MAX_INPUT) this.sageBuffer += ch + return render.sagePrompt(this.sageBuffer) + } + return render.sagePrompt(this.sageBuffer) + } + loadScript(force = false) { let stat = null try { @@ -1092,6 +1145,30 @@ class Runa { return null } + // Map glyph legend (issue #11): the exported LEGEND finally has a reader. + if (key.matches(msg, 'l') && !this.field) { + this.legendOpen = true + return null + } + if (this.legendOpen) { + if (key.matches(msg, 'escape', 'l', 'enter')) { + this.legendOpen = false + this.say('cerraste la leyenda de glifos') + } else { + this.say('esc o l para cerrar la leyenda') + } + return null + } + + // The sage conversation swallows printable input while open (issue #11). + if (this.sageSession && this.sageSession.active) { + if (key.matches(msg, 'escape')) { + this.sageSession.close('el sabio asiente y espera') + return null + } + return this.sageKey(msg) + } + if (this.field && this.field.combat) { if (key.matches(msg, 'space', 'enter', 'f')) this.advanceCombat() else if (key.matches(msg, 't')) this.returnToCity() @@ -1224,6 +1301,21 @@ class Runa { this.restAtTavern() break + case 'sage': + this.sageSession = new SageSession( + null, + () => { + try { return fs.readFileSync(SCRIPT_PATH, 'utf8') } catch { return '' } + }, + (next) => fs.writeFileSync(SCRIPT_PATH, next), + (lines) => { + for (const line of lines || []) this.say(line) + if (this.activeSlot) this.loadScript(true) + this.sageSession = null + } + ).start() + break + default: this.say('no se que es esto') } @@ -1574,6 +1666,23 @@ class Runa { const city = MAPS[this.walker.mapId] const nearby = this.nearbyNpc(2) + // Legend overlay (issue #11): LEGEND was exported but never shown anywhere. + if (this.legendOpen) { + const { LEGEND } = require('./map.js') + const rows = Object.entries(LEGEND || {}).map(([glyph, meaning]) => `${glyph} ${meaning}`) + const width = Math.min(this.width - 4, 46) + const height = Math.max(6, Math.min(rows.length + 4, this.height - 4)) + return render.compose({ + width: this.width, + height: this.height, + title: 'runa', + mainCaption: 'glifos del mapa', + main: render.box(`leyenda de glifos\n\n${rows.join('\n')}`, width, height), + stats: base.stats, + log: base.log, + footer: 'esc o l cerrar la leyenda' + }) + } return render.mapScreen({ ...base, place: nearby diff --git a/lib/map.js b/lib/map.js index 5daff6c..a139556 100755 --- a/lib/map.js +++ b/lib/map.js @@ -370,6 +370,18 @@ const CITY_NPCS = [ anchorY: NPC_SPRITES.villager.length - 1, color: 'green', line: 'cuido los canteros de la plaza y los jardines de la ciudad' + }, + { + id: 'sabio', + name: 'el sabio', + role: 'sabio', + x: 155, + y: 130, + sprite: NPC_SPRITES.priest, + anchorY: NPC_SPRITES.priest.length - 1, + color: 'blue', + action: { kind: 'sage' }, + line: 'contame tu estrategia en palabras y te la escribo' } ] diff --git a/lib/render.js b/lib/render.js index 8a4681e..fc68d17 100755 --- a/lib/render.js +++ b/lib/render.js @@ -1411,8 +1411,15 @@ function shopScreen(m) { }) } +/** One-line prompt for the sage conversation (issue #11). */ +function sagePrompt(buffer) { + const shown = String(buffer || '') + return `el sabio: "${shown}" (enter para preguntar, esc para salir)` +} + module.exports = { titleScreen, + sagePrompt, newGameScreen, LOGO, // constants diff --git a/lib/sage-npc.js b/lib/sage-npc.js new file mode 100644 index 0000000..6e8ec9c --- /dev/null +++ b/lib/sage-npc.js @@ -0,0 +1,73 @@ +/** + * The Sage NPC (issue #11): wires the orphaned `lib/sage.js` translator into + * the game so natural-language rules reach `script.txt` without a text editor. + * + * Flow: player talks to the sage (`e`) -> game enters sage mode -> + * bare-tui textinput collects one sentence -> Sage.ask() translates it -> + * on success the returned rule block is appended to script.txt and picked up + * by the normal script reload path. + */ + +const fs = require('bare-fs') + +/** + * One conversation with the sage. The main loop feeds keystrokes in through + * handle() while active, and receives the closing lines via onDone. + */ +class SageSession { + constructor(createInput, readScript, writeScript, onDone) { + this.createInput = createInput + this.readScript = readScript + this.writeScript = writeScript + this.onDone = onDone + this.say = [] + this.closed = false + this.lines = [] + } + + start() { + const { Sage } = require('./sage.js') + this.sage = new Sage() + this.say.push('el sabio te escucha. decile una regla en palabras.') + return this + } + + get active() { + return !this.closed + } + + /** + * Feed one sentence to the sage. + * @param {string} sentence + */ + ask(sentence) { + if (this.closed) return false + const result = this.sage.ask(String(sentence ?? '')) + for (const line of result.say || []) this.say.push(line) + + if (result.ok && result.script) { + try { + const current = String(this.readScript() ?? '') + const next = + current.trimEnd() + (current.trim() ? '\n' : '') + result.script + '\n' + this.writeScript(next) + this.say.push('la regla quedo escrita en tu script') + } catch { + this.say.push('no pude escribir el script; intenta con ? y tu editor') + } + } else if (result.examples && result.examples.length) { + this.say.push(`probá: ${result.examples[0]}`) + } + + return true + } + + close(message) { + if (message && this.say[this.say.length - 1] !== message) this.say.push(message) + this.closed = true + if (this.onDone) this.onDone(this.say) + return true + } +} + +module.exports = { SageSession } diff --git a/test/index.js b/test/index.js index 44d13eb..65fc079 100644 --- a/test/index.js +++ b/test/index.js @@ -505,7 +505,8 @@ test('city art remains rectangular and walkable', (t) => { t.ok(cityText.includes('[== jarra ==]'), 'the tavern has its own half-timbered sign') t.ok(cityText.includes('fragua (())'), 'the smithy exposes a working forge bay') t.ok(cityText.includes('[]__[]__[]'), 'the armoury has a crenellated silhouette') - t.is(city.npcs.length, 9, 'the city has nine static residents') + // Issue #11 wired the sage NPC into the city, so the roster grew by one. + t.is(city.npcs.length, 10, 'the city has ten static residents (incl. the sage)') t.ok(NPC_MASTER_SPRITES.guard.length >= 20, 'the faithful high-resolution knight is preserved') t.ok( NPC_MASTER_SPRITES.guard.some((line) => line.includes('hjw')), @@ -1207,3 +1208,35 @@ test('the world boss animates powers with real field damage', (t) => { game.drain(game.field.boss.touch(game.field.player, game.field.time + 20)) t.is(game.player.hp, life - 6, 'field contact updates the persistent character sheet') }) + + +test('el sabio traduce una frase y la agrega al script (issue #11)', (t) => { + const { SageSession } = require('../lib/sage-npc.js') + let written = null + const session = new SageSession( + null, + () => '?hp < 8\n use potion\n', + (next) => { written = next }, + () => {} + ).start() + + t.ok(session.active, 'session starts active') + session.ask('usa la ballesta si esta lejos') + t.ok(written !== null, 'script was written') + if (written) { + t.ok(written.includes('equip crossbow'), 'translated rule appended') + t.ok(written.startsWith('?hp < 8'), 'existing script preserved') + } +}) + +test('el sabio sobrevive a frases que no entiende (issue #11)', (t) => { + const { SageSession } = require('../lib/sage-npc.js') + let writes = 0 + let closed = false + const session = new SageSession(null, () => '', () => { writes++ }, () => { closed = true }).start() + session.ask('xyzzy blorp quux') + t.is(writes, 0, 'nothing written for nonsense') + session.close('chau') + t.ok(closed, 'session closes') + t.not(session.active, 'inactive after close') +})