codexivo 0.0.0-stage → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/world.ts ADDED
@@ -0,0 +1,518 @@
1
+ import type { NativeContext, NativeValue, ProcedureDoc } from './runtime/values';
2
+
3
+ /**
4
+ * Modo mundo: el mismo lenguaje con procedimientos para mover un robot en una cuadrícula, como Karel.
5
+ * Este módulo define el formato del mundo, sus reglas y sus eventos; el sitio solo lo dibuja.
6
+ *
7
+ * Coordenadas: `x` crece hacia el este y `y` hacia el norte; la casilla de la esquina suroeste es (1, 1).
8
+ */
9
+
10
+ export type Direction = 'norte' | 'este' | 'sur' | 'oeste';
11
+
12
+ /** Una cantidad de zumbadores: un entero ≥ 0 o `'infinito'` (JSON no tiene `Infinity`). */
13
+ export type BeeperCount = number | 'infinito';
14
+
15
+ /** Pared en el lado `side` de la casilla (x, y). La misma pared es el lado opuesto de la casilla vecina. */
16
+ export interface Wall {
17
+ x: number;
18
+ y: number;
19
+ side: Direction;
20
+ }
21
+
22
+ export interface BeeperPile {
23
+ x: number;
24
+ y: number;
25
+ count: BeeperCount;
26
+ }
27
+
28
+ export interface Robot {
29
+ x: number;
30
+ y: number;
31
+ facing: Direction;
32
+ /** Zumbadores en la mochila. */
33
+ beepers: BeeperCount;
34
+ }
35
+
36
+ /** Formato de un mundo, versión 1. Es el que guardan el editor de mundos y los retos del sitio. */
37
+ export interface WorldDefinition {
38
+ version: 1;
39
+ /** Casillas de oeste a este. */
40
+ width: number;
41
+ /** Casillas de sur a norte. */
42
+ height: number;
43
+ walls?: Wall[];
44
+ beepers?: BeeperPile[];
45
+ robot: Robot;
46
+ }
47
+
48
+ /**
49
+ * Estado de un mundo en un momento dado. Tiene el mismo formato que `WorldDefinition` (se puede usar como
50
+ * mundo inicial de otra ejecución), más si el robot sigue encendido. Las paredes salen normalizadas: solo
51
+ * lados `norte` y `este`, sin repetir, y sin los bordes de la cuadrícula.
52
+ */
53
+ export interface WorldState extends Required<WorldDefinition> {
54
+ robot: Robot & { on: boolean };
55
+ }
56
+
57
+ export interface WorldValidationError {
58
+ /** Dónde está el problema, como `robot.x` o `walls[2].side`. */
59
+ path: string;
60
+ message: string;
61
+ }
62
+
63
+ export const DIRECTIONS: readonly Direction[] = ['norte', 'este', 'sur', 'oeste'];
64
+
65
+ const MAX_SIZE = 100;
66
+
67
+ // ── validación ───────────────────────────────────────────────────────────────
68
+
69
+ const isObject = (value: unknown): value is Record<string, unknown> =>
70
+ typeof value === 'object' && value !== null && !Array.isArray(value);
71
+
72
+ /**
73
+ * Revisa que `input` (por ejemplo, el resultado de `JSON.parse`) sea un mundo válido. Nunca lanza: regresa el
74
+ * mundo o la lista de problemas, con mensajes para quien edita el mundo.
75
+ */
76
+ export const validateWorld = (
77
+ input: unknown,
78
+ ): { world: WorldDefinition; errors: [] } | { world?: undefined; errors: WorldValidationError[] } => {
79
+ const errors: WorldValidationError[] = [];
80
+ const fail = (path: string, message: string) => errors.push({ path, message });
81
+
82
+ if (!isObject(input)) {
83
+ fail('', 'El mundo tiene que ser un objeto JSON.');
84
+ return { errors };
85
+ }
86
+ if (input.version !== 1) {
87
+ fail('version', `La versión del formato tiene que ser 1, pero es ${JSON.stringify(input.version)}.`);
88
+ }
89
+
90
+ const size = (key: 'width' | 'height', what: string): number => {
91
+ const value = input[key];
92
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > MAX_SIZE) {
93
+ fail(
94
+ key,
95
+ `\`${key}\` (${what}) tiene que ser un entero entre 1 y ${MAX_SIZE}, pero es ${JSON.stringify(value)}.`,
96
+ );
97
+ return 0;
98
+ }
99
+ return value;
100
+ };
101
+ const width = size('width', 'casillas de oeste a este');
102
+ const height = size('height', 'casillas de sur a norte');
103
+
104
+ const position = (value: Record<string, unknown>, path: string) => {
105
+ for (const [axis, limit] of [
106
+ ['x', width],
107
+ ['y', height],
108
+ ] as const) {
109
+ const coordinate = value[axis];
110
+ if (typeof coordinate !== 'number' || !Number.isInteger(coordinate)) {
111
+ fail(
112
+ `${path}.${axis}`,
113
+ `\`${path}.${axis}\` tiene que ser un número entero, pero es ${JSON.stringify(coordinate)}.`,
114
+ );
115
+ } else if (limit > 0 && (coordinate < 1 || coordinate > limit)) {
116
+ fail(`${path}.${axis}`, `\`${path}.${axis}\` tiene que estar entre 1 y ${limit}, pero es ${coordinate}.`);
117
+ }
118
+ }
119
+ };
120
+ const direction = (value: unknown, path: string) => {
121
+ if (!DIRECTIONS.includes(value as Direction)) {
122
+ fail(path, `\`${path}\` tiene que ser "norte", "este", "sur" u "oeste", pero es ${JSON.stringify(value)}.`);
123
+ }
124
+ };
125
+ const count = (value: unknown, path: string) => {
126
+ if (value === 'infinito') return;
127
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
128
+ fail(
129
+ path,
130
+ `\`${path}\` tiene que ser un entero de 0 en adelante o "infinito", pero es ${JSON.stringify(value)}.`,
131
+ );
132
+ }
133
+ };
134
+ const list = (key: 'walls' | 'beepers', check: (item: Record<string, unknown>, path: string) => void) => {
135
+ const value = input[key];
136
+ if (value === undefined) return;
137
+ if (!Array.isArray(value)) {
138
+ fail(key, `\`${key}\` tiene que ser una lista.`);
139
+ return;
140
+ }
141
+ value.forEach((item, index) => {
142
+ const path = `${key}[${index}]`;
143
+ if (!isObject(item)) fail(path, `\`${path}\` tiene que ser un objeto.`);
144
+ else check(item, path);
145
+ });
146
+ };
147
+
148
+ list('walls', (wall, path) => {
149
+ position(wall, path);
150
+ direction(wall.side, `${path}.side`);
151
+ });
152
+ const piles = new Set<string>();
153
+ list('beepers', (pile, path) => {
154
+ position(pile, path);
155
+ count(pile.count, `${path}.count`);
156
+ const key = `${pile.x},${pile.y}`;
157
+ if (piles.has(key)) fail(path, `La casilla (${pile.x}, ${pile.y}) aparece dos veces en \`beepers\`.`);
158
+ piles.add(key);
159
+ });
160
+
161
+ if (!isObject(input.robot)) {
162
+ fail('robot', 'Falta `robot`, con su posición, orientación y zumbadores.');
163
+ } else {
164
+ position(input.robot, 'robot');
165
+ direction(input.robot.facing, 'robot.facing');
166
+ count(input.robot.beepers, 'robot.beepers');
167
+ }
168
+
169
+ if (errors.length > 0) return { errors };
170
+ return { world: input as unknown as WorldDefinition, errors: [] };
171
+ };
172
+
173
+ // ── mundo en ejecución ───────────────────────────────────────────────────────
174
+
175
+ const LEFT: Record<Direction, Direction> = { norte: 'oeste', oeste: 'sur', sur: 'este', este: 'norte' };
176
+ const RIGHT: Record<Direction, Direction> = { norte: 'este', este: 'sur', sur: 'oeste', oeste: 'norte' };
177
+ const STEP: Record<Direction, [number, number]> = { norte: [0, 1], este: [1, 0], sur: [0, -1], oeste: [-1, 0] };
178
+
179
+ const toNumber = (count: BeeperCount): number => (count === 'infinito' ? Infinity : count);
180
+ const toCount = (value: number): BeeperCount => (value === Infinity ? 'infinito' : value);
181
+
182
+ /** Lo que el evento `robot` dice del robot antes y después de una acción. */
183
+ export interface RobotSnapshot {
184
+ x: number;
185
+ y: number;
186
+ facing: Direction;
187
+ beepers: BeeperCount;
188
+ on: boolean;
189
+ }
190
+
191
+ export type RobotAction = 'avanzar' | 'girar_izquierda' | 'recoger_zumbador' | 'dejar_zumbador' | 'apagar';
192
+
193
+ /** Evento `robot`: uno por acción, para que el sitio anime sin recalcular nada. */
194
+ export interface RobotEvent {
195
+ type: 'robot';
196
+ action: RobotAction;
197
+ before: RobotSnapshot;
198
+ after: RobotSnapshot;
199
+ /** Zumbadores que quedaron en la casilla, en `recoger_zumbador` y `dejar_zumbador`. */
200
+ cell?: { x: number; y: number; beepers: BeeperCount };
201
+ }
202
+
203
+ export interface World {
204
+ /** Procedimientos del modo mundo; se pasan a la máquina (lo hace `MachineOptions.world`). */
205
+ natives: Record<string, NativeValue>;
206
+ /** Estado actual del mundo, serializable. */
207
+ state(): WorldState;
208
+ }
209
+
210
+ /**
211
+ * Crea un mundo listo para una ejecución. Lanza un `Error` si la definición no es válida; usa `validateWorld`
212
+ * antes para mostrar los problemas a quien editó el mundo.
213
+ */
214
+ export const createWorld = (definition: WorldDefinition): World => {
215
+ const { world, errors } = validateWorld(definition);
216
+ if (!world) throw new Error(`El mundo no es válido: ${errors.map(error => error.message).join(' ')}`);
217
+
218
+ const { width, height } = world;
219
+ const walls = new Set<string>();
220
+ const wallKey = (x: number, y: number, side: Direction): string => {
221
+ // Cada pared se guarda como el lado norte o este de una casilla.
222
+ if (side === 'sur') return `n:${x}:${y - 1}`;
223
+ if (side === 'oeste') return `e:${x - 1}:${y}`;
224
+ return `${side === 'norte' ? 'n' : 'e'}:${x}:${y}`;
225
+ };
226
+ for (const wall of world.walls ?? []) walls.add(wallKey(wall.x, wall.y, wall.side));
227
+
228
+ const beepers = new Map<string, number>();
229
+ for (const pile of world.beepers ?? []) {
230
+ if (toNumber(pile.count) > 0) beepers.set(`${pile.x},${pile.y}`, toNumber(pile.count));
231
+ }
232
+
233
+ const robot = { ...world.robot, beepers: toNumber(world.robot.beepers), on: true };
234
+
235
+ const isOpen = (x: number, y: number, side: Direction): boolean => {
236
+ const [dx, dy] = STEP[side];
237
+ const nx = x + dx;
238
+ const ny = y + dy;
239
+ if (nx < 1 || ny < 1 || nx > width || ny > height) return false;
240
+ return !walls.has(wallKey(x, y, side));
241
+ };
242
+
243
+ const snapshotRobot = (): RobotSnapshot => ({
244
+ x: robot.x,
245
+ y: robot.y,
246
+ facing: robot.facing,
247
+ beepers: toCount(robot.beepers),
248
+ on: robot.on,
249
+ });
250
+ const where = () => `El robot está en (${robot.x}, ${robot.y}) mirando al ${robot.facing}.`;
251
+
252
+ const action = (name: RobotAction, run: (context: NativeContext) => RobotEvent['cell'] | void): NativeValue => ({
253
+ kind: 'nativo',
254
+ name,
255
+ arity: 0,
256
+ doc: worldDocs[name]!,
257
+ call: (_args, context) => {
258
+ if (!robot.on) {
259
+ throw context.fail(
260
+ 'ROBOT_APAGADO',
261
+ `El robot ya está apagado; no puede \`${name}\`.`,
262
+ '`apagar()` va al final: después de apagarlo, el robot ya no hace nada.',
263
+ );
264
+ }
265
+ const before = snapshotRobot();
266
+ const cell = run(context);
267
+ context.emit({ type: 'robot', action: name, before, after: snapshotRobot(), ...(cell ? { cell } : {}) });
268
+ return null;
269
+ },
270
+ });
271
+ const question = (name: string, answer: () => boolean): NativeValue => ({
272
+ kind: 'nativo',
273
+ name,
274
+ arity: 0,
275
+ doc: worldDocs[name]!,
276
+ call: answer,
277
+ });
278
+
279
+ const actions: NativeValue[] = [
280
+ action('avanzar', context => {
281
+ if (!isOpen(robot.x, robot.y, robot.facing)) {
282
+ const [dx, dy] = STEP[robot.facing];
283
+ const edge = robot.x + dx < 1 || robot.y + dy < 1 || robot.x + dx > width || robot.y + dy > height;
284
+ throw context.fail(
285
+ 'CHOQUE_CON_PARED',
286
+ `El robot chocó con ${edge ? 'el borde del mundo' : 'una pared'}. ${where()}`,
287
+ 'Pregunta antes con `frente_libre()`.',
288
+ );
289
+ }
290
+ const [dx, dy] = STEP[robot.facing];
291
+ robot.x += dx;
292
+ robot.y += dy;
293
+ }),
294
+ action('girar_izquierda', () => {
295
+ robot.facing = LEFT[robot.facing];
296
+ }),
297
+ action('recoger_zumbador', context => {
298
+ const key = `${robot.x},${robot.y}`;
299
+ const here = beepers.get(key) ?? 0;
300
+ if (here === 0) {
301
+ throw context.fail(
302
+ 'SIN_ZUMBADORES',
303
+ `No hay zumbadores que recoger. ${where()}`,
304
+ 'Pregunta antes con `hay_zumbador()`.',
305
+ );
306
+ }
307
+ if (here !== Infinity) {
308
+ if (here === 1) beepers.delete(key);
309
+ else beepers.set(key, here - 1);
310
+ }
311
+ if (robot.beepers !== Infinity) robot.beepers += 1;
312
+ return { x: robot.x, y: robot.y, beepers: toCount(beepers.get(key) ?? 0) };
313
+ }),
314
+ action('dejar_zumbador', context => {
315
+ if (robot.beepers === 0) {
316
+ throw context.fail(
317
+ 'MOCHILA_VACÍA',
318
+ `El robot no tiene zumbadores que dejar. ${where()}`,
319
+ 'Pregunta antes con `tengo_zumbadores()`.',
320
+ );
321
+ }
322
+ if (robot.beepers !== Infinity) robot.beepers -= 1;
323
+ const key = `${robot.x},${robot.y}`;
324
+ const here = beepers.get(key) ?? 0;
325
+ if (here !== Infinity) beepers.set(key, here + 1);
326
+ return { x: robot.x, y: robot.y, beepers: toCount(beepers.get(key)!) };
327
+ }),
328
+ action('apagar', () => {
329
+ robot.on = false;
330
+ }),
331
+ question('frente_libre', () => isOpen(robot.x, robot.y, robot.facing)),
332
+ question('izquierda_libre', () => isOpen(robot.x, robot.y, LEFT[robot.facing])),
333
+ question('derecha_libre', () => isOpen(robot.x, robot.y, RIGHT[robot.facing])),
334
+ question('hay_zumbador', () => (beepers.get(`${robot.x},${robot.y}`) ?? 0) > 0),
335
+ question('tengo_zumbadores', () => robot.beepers > 0),
336
+ ...DIRECTIONS.map(direction => question(`mirando_al_${direction}`, () => robot.facing === direction)),
337
+ ];
338
+
339
+ const state = (): WorldState => {
340
+ const normalized: Wall[] = [...walls]
341
+ .map(key => {
342
+ const [kind, x, y] = key.split(':');
343
+ return { x: Number(x), y: Number(y), side: kind === 'n' ? ('norte' as const) : ('este' as const) };
344
+ })
345
+ .filter(wall =>
346
+ wall.side === 'norte'
347
+ ? wall.y >= 1 && wall.y < height && wall.x >= 1
348
+ : wall.x >= 1 && wall.x < width && wall.y >= 1,
349
+ )
350
+ .sort((a, b) => a.y - b.y || a.x - b.x || a.side.localeCompare(b.side));
351
+ const piles: BeeperPile[] = [...beepers]
352
+ .map(([key, count]) => {
353
+ const [x, y] = key.split(',').map(Number) as [number, number];
354
+ return { x, y, count: toCount(count) };
355
+ })
356
+ .sort((a, b) => a.y - b.y || a.x - b.x);
357
+ return {
358
+ version: 1,
359
+ width,
360
+ height,
361
+ walls: normalized,
362
+ beepers: piles,
363
+ robot: { x: robot.x, y: robot.y, facing: robot.facing, beepers: toCount(robot.beepers), on: robot.on },
364
+ };
365
+ };
366
+
367
+ return { natives: Object.fromEntries(actions.map(native => [native.name, native])), state };
368
+ };
369
+
370
+ // ── documentación ────────────────────────────────────────────────────────────
371
+
372
+ /** Mundo con el que se ejecutan los ejemplos del catálogo del modo mundo. */
373
+ export const EXAMPLE_WORLD: WorldDefinition = {
374
+ version: 1,
375
+ width: 3,
376
+ height: 3,
377
+ walls: [{ x: 1, y: 1, side: 'este' }],
378
+ beepers: [{ x: 1, y: 1, count: 2 }],
379
+ robot: { x: 1, y: 1, facing: 'norte', beepers: 1 },
380
+ };
381
+
382
+ const worldDoc = (description: string, code: string, result: string, question = false): ProcedureDoc => ({
383
+ level: 'principiante',
384
+ category: 'mundo',
385
+ params: [],
386
+ returns: question ? 'lógico' : 'nulo',
387
+ description,
388
+ examples: [{ code, result }],
389
+ });
390
+
391
+ /** Documentación de los procedimientos del modo mundo, para el catálogo. */
392
+ export const worldDocs: Readonly<Record<string, ProcedureDoc>> = {
393
+ avanzar: worldDoc(
394
+ 'Avanza una casilla hacia donde mira el robot. Chocar con una pared es un error.',
395
+ 'avanzar()\nmirando_al_norte()',
396
+ 'verdadero',
397
+ ),
398
+ girar_izquierda: worldDoc('Gira 90° a la izquierda.', 'girar_izquierda()\nmirando_al_oeste()', 'verdadero'),
399
+ recoger_zumbador: worldDoc(
400
+ 'Toma un zumbador de la casilla y lo guarda en la mochila.',
401
+ 'recoger_zumbador()\nhay_zumbador()',
402
+ 'verdadero',
403
+ ),
404
+ dejar_zumbador: worldDoc(
405
+ 'Deja un zumbador de la mochila en la casilla.',
406
+ 'dejar_zumbador()\ntengo_zumbadores()',
407
+ 'falso',
408
+ ),
409
+ apagar: worldDoc(
410
+ 'Apaga el robot. Después ya no puede hacer ninguna acción.',
411
+ 'apagar()\nfrente_libre()',
412
+ 'verdadero',
413
+ ),
414
+ frente_libre: worldDoc('`verdadero` si no hay pared enfrente del robot.', 'frente_libre()', 'verdadero', true),
415
+ izquierda_libre: worldDoc(
416
+ '`verdadero` si no hay pared a la izquierda del robot.',
417
+ 'izquierda_libre()',
418
+ 'falso',
419
+ true,
420
+ ),
421
+ derecha_libre: worldDoc('`verdadero` si no hay pared a la derecha del robot.', 'derecha_libre()', 'falso', true),
422
+ hay_zumbador: worldDoc(
423
+ '`verdadero` si hay al menos un zumbador en la casilla del robot.',
424
+ 'hay_zumbador()',
425
+ 'verdadero',
426
+ true,
427
+ ),
428
+ tengo_zumbadores: worldDoc(
429
+ '`verdadero` si hay al menos un zumbador en la mochila.',
430
+ 'tengo_zumbadores()',
431
+ 'verdadero',
432
+ true,
433
+ ),
434
+ mirando_al_norte: worldDoc('`verdadero` si el robot mira al norte.', 'mirando_al_norte()', 'verdadero', true),
435
+ mirando_al_este: worldDoc('`verdadero` si el robot mira al este.', 'mirando_al_este()', 'falso', true),
436
+ mirando_al_sur: worldDoc('`verdadero` si el robot mira al sur.', 'mirando_al_sur()', 'falso', true),
437
+ mirando_al_oeste: worldDoc('`verdadero` si el robot mira al oeste.', 'mirando_al_oeste()', 'falso', true),
438
+ };
439
+
440
+ // ── objetivos de un reto ─────────────────────────────────────────────────────
441
+
442
+ /**
443
+ * Lo que tiene que cumplir el mundo al terminar. Lo que no se indica, no se revisa: así un reto puede pedir
444
+ * solo «termina en (5, 1)» o solo «recoge todos los zumbadores».
445
+ */
446
+ export interface WorldGoal {
447
+ robot?: Partial<Robot & { on: boolean }>;
448
+ /** Zumbadores esperados en esas casillas (`0` para «vacía»). */
449
+ beepers?: BeeperPile[];
450
+ /** Con `true`, las casillas que no están en `beepers` tienen que quedar vacías. */
451
+ onlyListedBeepers?: boolean;
452
+ }
453
+
454
+ export interface GoalDifference {
455
+ /** Qué se revisó, como `robot.x` o `beepers(3, 1)`. */
456
+ field: string;
457
+ expected: unknown;
458
+ actual: unknown;
459
+ message: string;
460
+ }
461
+
462
+ /** Compara el mundo final con un objetivo. Es una función pura. */
463
+ export const checkGoal = (state: WorldState, goal: WorldGoal): { ok: boolean; differences: GoalDifference[] } => {
464
+ const differences: GoalDifference[] = [];
465
+ const robotLabels: Record<keyof WorldState['robot'], string> = {
466
+ x: 'la columna (x)',
467
+ y: 'la fila (y)',
468
+ facing: 'la orientación',
469
+ beepers: 'los zumbadores en la mochila',
470
+ on: 'si está encendido',
471
+ };
472
+ for (const [field, expected] of Object.entries(goal.robot ?? {}) as Array<[keyof WorldState['robot'], unknown]>) {
473
+ if (expected === undefined) continue;
474
+ const actual = state.robot[field];
475
+ if (actual !== expected) {
476
+ differences.push({
477
+ field: `robot.${field}`,
478
+ expected,
479
+ actual,
480
+ message:
481
+ field === 'on'
482
+ ? expected
483
+ ? 'El robot tenía que quedar encendido.'
484
+ : 'El robot tenía que terminar apagado: falta `apagar()`.'
485
+ : `En ${robotLabels[field]} se esperaba ${JSON.stringify(expected)}, pero quedó ${JSON.stringify(actual)}.`,
486
+ });
487
+ }
488
+ }
489
+
490
+ const actualPiles = new Map(state.beepers.map(pile => [`${pile.x},${pile.y}`, pile.count]));
491
+ const listed = new Set<string>();
492
+ for (const pile of goal.beepers ?? []) {
493
+ const key = `${pile.x},${pile.y}`;
494
+ listed.add(key);
495
+ const actual = actualPiles.get(key) ?? 0;
496
+ if (actual !== pile.count) {
497
+ differences.push({
498
+ field: `beepers(${pile.x}, ${pile.y})`,
499
+ expected: pile.count,
500
+ actual,
501
+ message: `En la casilla (${pile.x}, ${pile.y}) se esperaban ${pile.count} zumbadores, pero hay ${actual}.`,
502
+ });
503
+ }
504
+ }
505
+ if (goal.onlyListedBeepers) {
506
+ for (const [key, count] of actualPiles) {
507
+ if (listed.has(key)) continue;
508
+ const [x, y] = key.split(',');
509
+ differences.push({
510
+ field: `beepers(${x}, ${y})`,
511
+ expected: 0,
512
+ actual: count,
513
+ message: `La casilla (${x}, ${y}) tenía que quedar vacía, pero tiene ${count} zumbadores.`,
514
+ });
515
+ }
516
+ }
517
+ return { ok: differences.length === 0, differences };
518
+ };