Skip to content
Open
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
109 changes: 109 additions & 0 deletions lib/game.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand All @@ -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.',
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +275 to +289

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Quality: NPC 'sage' path doesn't show the sage's opening lines

The case 'sage' block in enter() builds and .start()s the SageSession but, unlike startSage(), never iterates this.sageSession.say to push the intro lines to the log. If the sage is entered through this path the player gets no on-screen prompt that the sage is listening. Prefer calling startSage() from both entry points to avoid the duplicated (and divergent) session-construction logic.

Was this helpful? React with 👍 / 👎

}

/**
* 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) {
Comment on lines +299 to +313

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Bug: sageKey reads msg.key; printable chars use msg.sequence

sageKey extracts the character with (msg && msg.key) || '', but key events in this codebase carry the typed character in msg.sequence (see the test harness typeText, which sends { type: 'key', sequence, ... }, and the fact that all other key routing uses key.matches). Reading msg.key likely yields undefined for printable input, so the sage buffer never fills and enter/backspace comparisons also fail. There is no integration test covering this path, so it would go unnoticed. Use msg.sequence for the character and key.matches(msg, 'enter'/'backspace') for control keys.

Was this helpful? React with 👍 / 👎

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 {
Expand Down Expand Up @@ -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
}

Comment on lines +1148 to +1162

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Bug: 'l' key hijacks sage input and opens the legend

In onKey, the legend toggle key.matches(msg, 'l') && !this.field is evaluated before the sageSession input branch. While the sage conversation is open in town, pressing the letter 'l' opens the legend overlay instead of appending to the sage buffer, so any sentence containing 'l' (extremely common in Spanish: 'la', 'el', 'ballesta') cannot be typed. Gate the legend toggle so it does not fire while a sage session is active (or move the sage/legend checks so an active session takes precedence).

Don't treat 'l' as the legend toggle while the sage is listening.:

if (key.matches(msg, 'l') && !this.field && !(this.sageSession && this.sageSession.active)) {
  this.legendOpen = true
  return null
}
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

// 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()
Expand Down Expand Up @@ -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
Comment on lines +1304 to +1317

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚨 Bug: Sage NPC is unreachable — interactNpc ignores kind 'sage'

The sabio is placed as a CITY_NPC with action: { kind: 'sage' } (map.js), but NPCs are activated through interactNpc, which only handles shop/church/tavern and silently returns for anything else. The case 'sage' block that actually starts the SageSession lives in enter()'s tile switch, which is only reached via walker.action() (tile enter descriptors) — no tile carries kind: 'sage'. So talking to the sabio just prints his line and never opens the conversation, leaving the feature (the PR's main goal) unreachable. Add a sage branch to interactNpc (e.g. call this.startSage()), which already handles the say-line display and reload wiring.

Route NPC 'sage' action to startSage() inside interactNpc.:

if (action.kind === 'tavern') {
  this.restAtTavern()
  return true
}
if (action.kind === 'sage') {
  this.startSage()
  return true
}
return true
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎


default:
this.say('no se que es esto')
}
Expand Down Expand Up @@ -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}`)
Comment on lines +1670 to +1672

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Bug: LEGEND is an array but rendered via Object.entries

In view() the legend overlay does Object.entries(LEGEND || {}).map(([glyph, meaning]) => ${glyph} ${meaning}), but LEGEND in map.js is an array of { glyph, text } objects. Object.entries on an array yields index keys and object values, so every row renders as 0 [object Object], 1 [object Object], … instead of the intended glyph/meaning. Iterate the array and read the fields explicitly.

Map the array of {glyph,text} entries directly.:

const rows = (LEGEND || []).map((e) => `${e.glyph}  ${e.text}`)
  • Apply fix

Check the box to apply the fix or reply for a change | Was this helpful? React with 👍 / 👎

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
Expand Down
12 changes: 12 additions & 0 deletions lib/map.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
]

Expand Down
7 changes: 7 additions & 0 deletions lib/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 73 additions & 0 deletions lib/sage-npc.js
Original file line number Diff line number Diff line change
@@ -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')

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Quality: Unused fs import in sage-npc.js

sage-npc.js requires bare-fs at the top, but SageSession receives its read/write functions via constructor injection and never touches fs directly. The import is dead and can be removed to keep the module's IO boundary honest.

Was this helpful? React with 👍 / 👎


/**
* 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 }
35 changes: 34 additions & 1 deletion test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')),
Expand Down Expand Up @@ -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')
})
Loading