-- robotkarol.lua — JSON-Schnittstelle zu Robot Karol Online (QuestV1) -- -- Copyright (C) 2026 Anselm Wagner -- -- This work may be distributed and/or modified under the conditions of -- the LaTeX Project Public License, either version 1.3c of this license -- or (at your option) any later version. The latest version of this -- license is in https://www.latex-project.org/lppl.txt and version -- 1.3c or later is part of all distributions of LaTeX version -- 2008-05-04 or later. -- -- This work has the LPPL maintenance status `maintained'. -- The Current Maintainer of this work is Anselm Wagner. -- This file is part of the work robotkarol (see robotkarol.sty). -- --------------------------------------------------------------------- -- Wird von robotkarol.sty unter LuaLaTeX geladen (Abschnitt 7 dort); -- der Kern ohne M.tex läuft auch allein unter texlua. Für Anwender -- beschreibt robotkarol-doc.pdf, Abschnitt „Aufgaben aus Robot Karol -- Online: JSON", was das Paket liest und schreibt. -- -- Das Format v1 ist das von Robot Karol Online. Maßgeblich ist dessen -- Quelltext, https://github.com/Entkenntnis/robot-karol-online — Pfade -- wie lib/commands/json.ts in den Kommentaren unten beziehen sich auf -- dieses Projekt, nicht auf Dateien von robotkarol. -- -- Normalform je Teilaufgabe — JSON-Konvention, aber mit -- Lua-Indizes: -- -- dimX, dimY, height Weltmaße -- karol, zielkarol { x, y, dir } wie im JSON (0-basiert) -- bricks, marks Startwelt, m[y+1][x+1] -- zbricks, zmarks Zielwelt, m[y+1][x+1] -- blocks, zblocks Quader beider Welten -- art "welt" | "frei" | "skript" -- titel tasks[n].title -- -- Der Matrixindex ist damit zugleich die robotkarol-Koordinate: -- bricks[y][x] ist die Zelle \ziegel[x=x, y=y]. Die JSON-Koordinate -- ist um eins kleiner. Die Kompression auf das kleinste Rechteck -- passiert ausschließlich in komprimieren()/dekomprimieren(). -- -- zblocks und zielkarol werden in robotkarol nicht gezeichnet (Quader -- und Karol haben dort keinen Zielzustand), aber mitgeführt, damit eine -- unveränderte Teilaufgabe beim Schreiben byte-gleich herauskommt. -- -- Fehler werfen error() mit einer Zeichenkette "robotkarol: …" -- (Ebene 0, ohne Lua-Ortsangabe); Warnungen landen in einer Liste. local M = {} M.null = setmetatable({}, { __tostring = function () return 'null' end }) -- Schlüsselreihenfolge der Objekte, schwach referenziert. Ein Lua- -- table ohne Eintrag hier gilt beim Schreiben als Liste. local ordnung = setmetatable({}, { __mode = 'k' }) local function fehler(text) error('robotkarol: ' .. text, 0) end M.fehler = fehler -- Objekt aus Schlüssel-Wert-Paaren in fester Reihenfolge. Paare mit -- nil als Wert entfallen (wie undefined bei JSON.stringify). function M.objekt(...) local t, reihe = {}, {} local n = select('#', ...) for i = 1, n, 2 do local k, v = select(i, ...) if v ~= nil then t[k] = v reihe[#reihe + 1] = k end end ordnung[t] = reihe return t end function M.ist_objekt(t) return type(t) == 'table' and ordnung[t] ~= nil end function M.schluessel(t) return ordnung[t] end -- --------------------------------------------------------------------- -- JSON lesen (RFC 8259, streng) -- --------------------------------------------------------------------- local leerraum = { [0x20] = true, [0x09] = true, [0x0A] = true, [0x0D] = true } local maske = { ['"'] = '"', ['\\'] = '\\', ['/'] = '/', b = '\b', f = '\f', n = '\n', r = '\r', t = '\t' } local MAXTIEFE = 200 function M.json_lesen(text) local pos = 1 local len = #text local function stelle(p) local zeile, spalte = 1, 1 for i = 1, p - 1 do if text:byte(i) == 0x0A then zeile, spalte = zeile + 1, 1 else spalte = spalte + 1 end end return 'Zeile ' .. zeile .. ', Spalte ' .. spalte end local function kaputt(was, p) p = p or pos if p > len then was = 'unerwartetes Dateiende' end fehler('JSON-Fehler (' .. stelle(p) .. '): ' .. was) end local function weiter() while pos <= len and leerraum[text:byte(pos)] do pos = pos + 1 end end local wert local function zeichenkette() pos = pos + 1 -- öffnendes " local teile = {} while true do local a = text:find('[%z\1-\31\\"]', pos) if not a then kaputt('Zeichenkette nicht beendet') end teile[#teile + 1] = text:sub(pos, a - 1) local c = text:sub(a, a) if c == '"' then pos = a + 1 return table.concat(teile) elseif c == '\\' then local e = text:sub(a + 1, a + 1) if maske[e] then teile[#teile + 1] = maske[e] pos = a + 2 elseif e == 'u' then local function hex(p) local h = text:sub(p, p + 3) if not h:match('^%x%x%x%x$') then kaputt('ungültige \\u-Folge', p - 2) end return tonumber(h, 16) end local cp = hex(a + 2) pos = a + 6 if cp >= 0xD800 and cp <= 0xDBFF then if text:sub(pos, pos + 1) ~= '\\u' then kaputt('hohes Surrogat ohne tiefes', a) end local lo = hex(pos + 2) if lo < 0xDC00 or lo > 0xDFFF then kaputt('hohes Surrogat ohne tiefes', a) end cp = 0x10000 + (cp - 0xD800) * 0x400 + (lo - 0xDC00) pos = pos + 6 elseif cp >= 0xDC00 and cp <= 0xDFFF then kaputt('tiefes Surrogat ohne hohes', a) end teile[#teile + 1] = utf8.char(cp) else kaputt('ungültige Maskierung \\' .. e, a) end else kaputt('Steuerzeichen in Zeichenkette', a) end end end local function zahl() local a = pos local s = text:match('^-?%d+', pos) if not s or s:match('^-?0%d') then kaputt('ungültige Zahl') end pos = pos + #s local ganz = true local f = text:match('^%.%d+', pos) if f then pos, ganz = pos + #f, false elseif text:sub(pos, pos) == '.' then kaputt('ungültige Zahl', a) end local e = text:match('^[eE][+-]?%d+', pos) if e then pos, ganz = pos + #e, false elseif text:find('^[eE]', pos) then kaputt('ungültige Zahl', a) end local n = tonumber(text:sub(a, pos - 1)) if ganz then n = math.tointeger(n) or n end return n end local function literal(wort, v) if text:sub(pos, pos + #wort - 1) ~= wort then kaputt('unerwartetes Zeichen') end pos = pos + #wort return v end wert = function (tiefe) if tiefe > MAXTIEFE then kaputt('zu tief verschachtelt') end weiter() local c = text:sub(pos, pos) if c == '{' then pos = pos + 1 local t, reihe = {}, {} ordnung[t] = reihe weiter() if text:sub(pos, pos) == '}' then pos = pos + 1 return t end while true do weiter() if text:sub(pos, pos) ~= '"' then kaputt('Schlüssel erwartet') end local k = zeichenkette() weiter() if text:sub(pos, pos) ~= ':' then kaputt('":" erwartet') end pos = pos + 1 if t[k] == nil then reihe[#reihe + 1] = k end t[k] = wert(tiefe + 1) -- doppelter Schlüssel: der letzte gilt (wie JSON.parse) weiter() c = text:sub(pos, pos) pos = pos + 1 if c == '}' then return t elseif c ~= ',' then kaputt('"," oder "}" erwartet', pos - 1) end end elseif c == '[' then pos = pos + 1 local t = {} weiter() if text:sub(pos, pos) == ']' then pos = pos + 1 return t end while true do t[#t + 1] = wert(tiefe + 1) weiter() c = text:sub(pos, pos) pos = pos + 1 if c == ']' then return t elseif c ~= ',' then kaputt('"," oder "]" erwartet', pos - 1) end end elseif c == '"' then return zeichenkette() elseif c == 't' then return literal('true', true) elseif c == 'f' then return literal('false', false) elseif c == 'n' then return literal('null', M.null) elseif c == '-' or c:match('%d') then return zahl() elseif c == '' then kaputt('unerwartetes Dateiende') else kaputt('unerwartetes Zeichen "' .. c .. '"') end end -- BOM tolerieren: Editoren unter Windows setzen eines if text:sub(1, 3) == '\239\187\191' then pos = 4 end local v = wert(1) weiter() if pos <= len then kaputt('Text nach dem Ende des JSON-Werts') end return v end -- --------------------------------------------------------------------- -- JSON schreiben — kompakt wie JSON.stringify ohne Einrückung, oder -- eingerückt: ein Wert je Zeile, auch in Listen; eine leere Liste oder -- ein leeres Objekt bekommt eine Leerzeile zwischen den Klammern. -- --------------------------------------------------------------------- local ersatz = { ['"'] = '\\"', ['\\'] = '\\\\', ['\b'] = '\\b', ['\f'] = '\\f', ['\n'] = '\\n', ['\r'] = '\\r', ['\t'] = '\\t' } local function zeichenkette_schreiben(s) return '"' .. s:gsub('[%z\1-\31\\"]', function (c) return ersatz[c] or string.format('\\u%04x', c:byte()) end) .. '"' end local function zahl_schreiben(n) if math.type(n) == 'integer' then return string.format('%d', n) end if n ~= n or n == math.huge or n == -math.huge then return 'null' -- wie JSON.stringify end if n == math.floor(n) and math.abs(n) < 2^53 then return string.format('%d', math.tointeger(n)) end local s for stellen = 15, 17 do s = string.format('%.' .. stellen .. 'g', n) if tonumber(s) == n then break end end -- JavaScript schreibt 1e-7, nicht 1e-07 return (s:gsub('e([+-])0*(%d)', 'e%1%2')) end local function schreiben(v, puffer, ein, tiefe) local typ = type(v) if typ == 'string' then puffer[#puffer + 1] = zeichenkette_schreiben(v) elseif typ == 'number' then puffer[#puffer + 1] = zahl_schreiben(v) elseif typ == 'boolean' then puffer[#puffer + 1] = v and 'true' or 'false' elseif v == M.null then puffer[#puffer + 1] = 'null' elseif typ == 'table' then -- ein = nil: kompakt; sonst Einrückung je Ebene local innen, aussen, trenner, paar = '', '', ',', ':' if ein then innen = '\n' .. ein:rep(tiefe + 1) aussen = '\n' .. ein:rep(tiefe) trenner, paar = ',' .. innen, ': ' end local reihe = ordnung[v] local auf, zu = '[', ']' local werte = {} if reihe then auf, zu = '{', '}' for _, k in ipairs(reihe) do if v[k] ~= nil then werte[#werte + 1] = k end end else for i = 1, #v do werte[i] = i end end puffer[#puffer + 1] = auf if #werte == 0 then if ein then puffer[#puffer + 1] = '\n' .. aussen end else puffer[#puffer + 1] = innen for n, k in ipairs(werte) do if n > 1 then puffer[#puffer + 1] = trenner end if reihe then puffer[#puffer + 1] = zeichenkette_schreiben(k) puffer[#puffer + 1] = paar end schreiben(v[k], puffer, ein, tiefe + 1) end puffer[#puffer + 1] = aussen end puffer[#puffer + 1] = zu else fehler('nicht als JSON darstellbar: ' .. typ) end end -- ein: Einrückung je Ebene (z. B. ' '); nil schreibt kompakt. function M.json_schreiben(v, ein) local puffer = {} schreiben(v, puffer, ein, 0) return table.concat(puffer) end -- --------------------------------------------------------------------- -- Ebenen: Kompression auf das kleinste Rechteck (Karol Online: -- lib/commands/json.ts, compress2dArray) -- --------------------------------------------------------------------- function M.matrix(dimX, dimY, vorgabe) local m = {} for y = 1, dimY do local zeile = {} for x = 1, dimX do zeile[x] = vorgabe end m[y] = zeile end return m end function M.komprimieren(m, dimX, dimY, vorgabe) local x0, x1, y0, y1 for y = 1, dimY do local zeile = m[y] for x = 1, dimX do if zeile[x] ~= vorgabe then if not x0 or x < x0 then x0 = x end if not x1 or x > x1 then x1 = x end if not y0 then y0 = y end y1 = y end end end if not x0 then -- kanonische Leerform, in dieser Schlüsselreihenfolge (wie Karol Online) return M.objekt('offsetX', -1, 'offsetY', -1, 'dimX', 0, 'dimY', 0, 'data', {}) end local data = {} for y = y0, y1 do local zeile = {} for x = x0, x1 do zeile[#zeile + 1] = m[y][x] end data[#data + 1] = zeile end return M.objekt('dimX', x1 - x0 + 1, 'dimY', y1 - y0 + 1, 'offsetX', x0 - 1, 'offsetY', y0 - 1, 'data', data) end local function ganzzahl(v) if math.type(v) == 'integer' then return v elseif type(v) == 'number' and v == math.floor(v) and math.abs(v) < 2^53 then return math.tointeger(v) -- 2.0 und 1e2 sind gültiges JSON end return nil end M.ganzzahl = ganzzahl local function feld_ganzzahl(obj, k, ort) local v = ganzzahl(obj[k]) if v == nil then fehler(ort .. ': "' .. k .. '" muss eine ganze Zahl sein') end return v end -- art: "zahl" (Ziegel, ganze Zahl ≥ 0) oder "bool" (Marken, Quader) function M.dekomprimieren(ebene, dimX, dimY, vorgabe, art, ort) if not M.ist_objekt(ebene) then fehler(ort .. ': Objekt erwartet') end local ox = feld_ganzzahl(ebene, 'offsetX', ort) local oy = feld_ganzzahl(ebene, 'offsetY', ort) local dx = feld_ganzzahl(ebene, 'dimX', ort) local dy = feld_ganzzahl(ebene, 'dimY', ort) local data = ebene.data if type(data) ~= 'table' or M.ist_objekt(data) then fehler(ort .. ': "data" muss eine Liste sein') end if dx < 0 or dy < 0 then fehler(ort .. ': negative Ausdehnung') end if #data ~= dy then fehler(ort .. ': "data" hat ' .. #data .. ' Zeilen statt ' .. dy) end local m = M.matrix(dimX, dimY, vorgabe) if dx == 0 or dy == 0 then for _, zeile in ipairs(data) do if type(zeile) ~= 'table' or #zeile ~= 0 then fehler(ort .. ': leere Ebene mit Inhalt in "data"') end end return m end if ox < 0 or oy < 0 or ox + dx > dimX or oy + dy > dimY then fehler(string.format('%s: Ausschnitt %d×%d bei (%d,%d) ragt aus der Welt %d×%d', ort, dx, dy, ox, oy, dimX, dimY)) end for ly = 1, dy do local zeile = data[ly] if type(zeile) ~= 'table' or M.ist_objekt(zeile) or #zeile ~= dx then fehler(ort .. ': Zeile ' .. ly .. ' von "data" hat nicht ' .. dx .. ' Einträge') end local ziel = m[oy + ly] for lx = 1, dx do local v = zeile[lx] if art == 'zahl' then local n = ganzzahl(v) if n == nil or n < 0 then fehler(string.format('%s: Zelle (%d,%d) ist keine ganze Zahl ≥ 0', ort, ox + lx - 1, oy + ly - 1)) end v = n elseif type(v) ~= 'boolean' then fehler(string.format('%s: Zelle (%d,%d) ist nicht true/false', ort, ox + lx - 1, oy + ly - 1)) end ziel[ox + lx] = v end end return m end -- --------------------------------------------------------------------- -- Welten -- --------------------------------------------------------------------- -- Grenzen der Oberfläche von Karol Online (components/modals/ -- ResizeWorldModal.tsx): jede dort baubare Welt muss sich laden lassen. M.GRENZE_XY = 100 -- hart M.GRENZE_Z = 10 -- weich (Warnung) local richtungen = { north = true, east = true, south = true, west = true } -- Sammelt gleichartige Befunde einer Welt zu einer Warnung, statt bei -- 10 000 Zellen 10 000 Meldungen auszugeben. local function befund(liste, ort, text, x, y) local k = ort .. '\0' .. text local b = liste[k] if b then b.anzahl = b.anzahl + 1 else b = { ort = ort, text = text, x = x, y = y, anzahl = 1 } liste[k] = b liste[#liste + 1] = b end end local function befunde_melden(liste, warnungen) for _, b in ipairs(liste) do local s = string.format('%s: %s, Zelle x=%d, y=%d', b.ort, b.text, b.x, b.y) if b.anzahl > 1 then s = s .. string.format(' (und %d weitere)', b.anzahl - 1) end warnungen[#warnungen + 1] = s end end -- SerialWorld → { dimX, dimY, height, karol, bricks, marks, blocks } function M.welt_lesen(w, ort, warnungen) if not M.ist_objekt(w) then fehler(ort .. ': Objekt erwartet') end local dimX = feld_ganzzahl(w, 'dimX', ort) local dimY = feld_ganzzahl(w, 'dimY', ort) local height = feld_ganzzahl(w, 'height', ort) if dimX < 1 or dimY < 1 or height < 1 then fehler(string.format('%s: Weltgröße %d×%d, Höhe %d ist ungültig', ort, dimX, dimY, height)) end if dimX > M.GRENZE_XY or dimY > M.GRENZE_XY then fehler(string.format('%s: Welt %d×%d ist größer als %d×%d (Grenze von Karol Online)', ort, dimX, dimY, M.GRENZE_XY, M.GRENZE_XY)) end if height > M.GRENZE_Z then warnungen[#warnungen + 1] = string.format( '%s: Höhe %d liegt über %d, der Grenze von Karol Online', ort, height, M.GRENZE_Z) end local k = w.karol if not M.ist_objekt(k) then fehler(ort .. ': "karol" fehlt') end local karol = { x = feld_ganzzahl(k, 'x', ort .. ', karol'), y = feld_ganzzahl(k, 'y', ort .. ', karol'), dir = k.dir, } if not richtungen[karol.dir] then fehler(ort .. ': unbekannte Blickrichtung "' .. tostring(karol.dir) .. '"') end for _, f in ipairs({ 'bricks', 'marks', 'blocks' }) do if w[f] == nil then fehler(ort .. ': "' .. f .. '" fehlt') end end local welt = { dimX = dimX, dimY = dimY, height = height, karol = karol, bricks = M.dekomprimieren(w.bricks, dimX, dimY, 0, 'zahl', ort .. ', bricks'), marks = M.dekomprimieren(w.marks, dimX, dimY, false, 'bool', ort .. ', marks'), blocks = M.dekomprimieren(w.blocks, dimX, dimY, false, 'bool', ort .. ', blocks'), } -- semantische Prüfungen (nur Warnungen); Zellen in robotkarol-Koordinaten local liste = {} if karol.x < 0 or karol.x >= dimX or karol.y < 0 or karol.y >= dimY then warnungen[#warnungen + 1] = string.format( '%s: Karol steht außerhalb der Welt (x=%d, y=%d)', ort, karol.x + 1, karol.y + 1) elseif welt.blocks[karol.y + 1][karol.x + 1] then warnungen[#warnungen + 1] = string.format( '%s: Karol steht auf einem Quader (x=%d, y=%d)', ort, karol.x + 1, karol.y + 1) end for y = 1, dimY do for x = 1, dimX do local b = welt.bricks[y][x] if b > height then befund(liste, ort, 'Stapel höher als die Welt (' .. height .. ')', x, y) end if welt.blocks[y][x] and (b > 0 or welt.marks[y][x]) then befund(liste, ort, 'Quader mit Ziegeln oder Marke in derselben Zelle', x, y) end end end befunde_melden(liste, warnungen) return welt end -- wie twoWorldsEqual (Karol Online, lib/commands/world.ts): Maße, Höhe -- und die drei Ebenen — Karol wird NICHT verglichen. Das ist keine -- Nachlässigkeit, sondern die Aufgabensemantik von Karol Online: eine -- Teilaufgabe, deren Welten sich nur in Karol unterscheiden, ist dort -- frei, und hier muss sie es auch sein. function M.weltenGleich(a, b) if a.dimX ~= b.dimX or a.dimY ~= b.dimY or a.height ~= b.height then return false end for y = 1, a.dimY do local ab, am, aq = a.bricks[y], a.marks[y], a.blocks[y] local bb, bm, bq = b.bricks[y], b.marks[y], b.blocks[y] for x = 1, a.dimX do if ab[x] ~= bb[x] or am[x] ~= bm[x] or aq[x] ~= bq[x] then return false end end end return true end function M.welt_json(dimX, dimY, height, karol, bricks, marks, blocks) return M.objekt( 'dimX', dimX, 'dimY', dimY, 'height', height, 'karol', M.objekt('x', karol.x, 'y', karol.y, 'dir', karol.dir), 'bricks', M.komprimieren(bricks, dimX, dimY, 0), 'marks', M.komprimieren(marks, dimX, dimY, false), 'blocks', M.komprimieren(blocks, dimX, dimY, false)) end -- --------------------------------------------------------------------- -- Aufgaben (QuestV1) -- --------------------------------------------------------------------- local function matrizen_gleich(a, b, dimX, dimY) for y = 1, dimY do for x = 1, dimX do if a[y][x] ~= b[y][x] then return false end end end return true end -- Teilaufgabe → Normalform. hat_skript: questScript ist gesetzt. function M.aufgabe_lesen(t, nr, hat_skript, warnungen) local ort = 'Aufgabe ' .. nr if not M.ist_objekt(t) then fehler(ort .. ': Objekt erwartet') end if type(t.title) ~= 'string' then fehler(ort .. ': "title" fehlt') end if t.start == nil or t.target == nil then fehler(ort .. ': "start" und "target" sind Pflicht') end local s = M.welt_lesen(t.start, ort .. ', Startwelt', warnungen) local z = M.welt_lesen(t.target, ort .. ', Zielwelt', warnungen) if s.dimX ~= z.dimX or s.dimY ~= z.dimY or s.height ~= z.height then fehler(string.format('%s: Start- und Zielwelt sind verschieden groß (%d×%d×%d / %d×%d×%d)', ort, s.dimX, s.dimY, s.height, z.dimX, z.dimY, z.height)) end if not matrizen_gleich(s.blocks, z.blocks, s.dimX, s.dimY) then warnungen[#warnungen + 1] = ort .. ': Quader in Start- und Zielwelt verschieden; in Karol Online ist die Aufgabe' .. ' damit unlösbar (Quader lassen sich nicht versetzen), robotkarol zeigt die der Startwelt' end local art if hat_skript then art = 'skript' elseif M.weltenGleich(s, z) then art = 'frei' else art = 'welt' end return { titel = t.title, art = art, dimX = s.dimX, dimY = s.dimY, height = s.height, karol = s.karol, zielkarol = z.karol, bricks = s.bricks, marks = s.marks, blocks = s.blocks, zbricks = z.bricks, zmarks = z.marks, zblocks = z.blocks, } end function M.aufgabe_json(a) return M.objekt( 'title', a.titel, 'start', M.welt_json(a.dimX, a.dimY, a.height, a.karol, a.bricks, a.marks, a.blocks), 'target', M.welt_json(a.dimX, a.dimY, a.height, a.zielkarol or a.karol, a.zbricks, a.zmarks, a.zblocks or a.blocks)) end -- Text einer QuestV1-Datei → { roh, titel, beschreibung, aufgaben, warnungen }. -- roh ist das geparste Objekt; es hält alle Felder, die beim Schreiben -- durchgereicht werden (program, language, questScript, chats, …). function M.quest_lesen(text) local q = M.json_lesen(text) if not M.ist_objekt(q) then fehler('keine Karol-Online-Aufgabe (oberste Ebene ist kein Objekt)') end if q.version ~= 'v1' then fehler('Formatversion "' .. tostring(q.version) .. '" wird nicht unterstützt (erwartet "v1")') end if type(q.title) ~= 'string' or type(q.description) ~= 'string' then fehler('"title" und "description" sind Pflicht') end if type(q.tasks) ~= 'table' or M.ist_objekt(q.tasks) then fehler('"tasks" muss eine Liste sein') end if q.questScript ~= nil and type(q.questScript) ~= 'string' then fehler('"questScript" muss eine Zeichenkette sein') end local hat_skript = q.questScript ~= nil and q.questScript ~= '' local warnungen, aufgaben = {}, {} for i, t in ipairs(q.tasks) do aufgaben[i] = M.aufgabe_lesen(t, i, hat_skript, warnungen) end return { roh = q, titel = q.title, beschreibung = q.description, aufgaben = aufgaben, warnungen = warnungen, } end -- Reihenfolge wie serializeQuest (Karol Online, lib/commands/json.ts); unbekannte -- Felder einer gelesenen Datei folgen in ihrer Reihenfolge. local quest_felder = { 'version', 'title', 'description', 'tasks', 'lng', 'editOptions', 'questScript', 'chats', 'language', 'program' } function M.quest_json(quest) local roh = quest.roh or M.objekt() local tasks = {} for i, a in ipairs(quest.aufgaben) do tasks[i] = M.aufgabe_json(a) end local werte = { version = 'v1', title = quest.titel or roh.title or '', description = quest.beschreibung or roh.description or '', tasks = tasks, } -- fehlende Felder sind nil: Zähler statt #, sonst verrutschen die Paare local paare, n, bekannt = {}, 0, {} local function paar(k, v) paare[n + 1], paare[n + 2], n = k, v, n + 2 end for _, k in ipairs(quest_felder) do bekannt[k] = true local v = werte[k] if v == nil then v = roh[k] end paar(k, v) end for _, k in ipairs(ordnung[roh] or {}) do if not bekannt[k] then paar(k, roh[k]) end end return M.objekt(table.unpack(paare, 1, n)) end -- Dateien schreibt robotkarol eingerückt (zwei Leerzeichen, wie ein -- JSON-Formatierer); kompakt = true liefert die Form von Karol Online. function M.quest_schreiben(quest, kompakt) return M.json_schreiben(M.quest_json(quest), not kompakt and ' ' or nil) end -- --------------------------------------------------------------------- -- Normalform → LaTeX-Quelltext (\karolquelltext) -- --------------------------------------------------------------------- -- Genau die Befehle, die ein Mensch getippt hätte. Pure Funktion: gibt -- den Text zurück (Zeilen mit \n, ohne Endzeile), schreibt nichts. -- -- kompakt = true gleiche Zellen zu Rechtecken (bisx/bisy); eine -- freie Aufgabe mit dimY = 1 als \karolzeile local langrichtung = { north = 'norden', east = 'osten', south = 'sueden', west = 'westen' } local zeilenfigur = { north = '^', east = '>', south = 'v', west = '<' } local function leer_in(m, dimX, dimY, vorgabe) for y = 1, dimY do for x = 1, dimX do if m[y][x] ~= vorgabe then return false end end end return true end -- Rechtecke gleicher Werte: von links oben zeilenweise, je Startzelle so -- weit nach rechts wie möglich, dann so weit nach unten, wie die ganze -- Zeile mitpasst. wert(x, y) liefert einen Vergleichsschlüssel oder nil -- (Zelle leer). Ohne kompakt jede Zelle einzeln. local function rechtecke(dimX, dimY, wert, kompakt) local fertig, liste = {}, {} for y = 1, dimY do fertig[y] = {} end for y = 1, dimY do for x = 1, dimX do local w = wert(x, y) if w ~= nil and not fertig[y][x] then local x2, y2 = x, y if kompakt then while x2 < dimX and not fertig[y][x2 + 1] and wert(x2 + 1, y) == w do x2 = x2 + 1 end local weiter = true while weiter and y2 < dimY do for xx = x, x2 do if fertig[y2 + 1][xx] or wert(xx, y2 + 1) ~= w then weiter = false break end end if weiter then y2 = y2 + 1 end end end for yy = y, y2 do for xx = x, x2 do fertig[yy][xx] = true end end liste[#liste + 1] = { x = x, y = y, x2 = x2, y2 = y2, w = w } end end end return liste end local function ort_text(r) local s = string.format('x=%d, y=%d', r.x, r.y) if r.x2 > r.x then s = s .. string.format(', bisx=%d', r.x2) end if r.y2 > r.y then s = s .. string.format(', bisy=%d', r.y2) end return s end -- Eine freie Welt mit einer Zeile als \karolzeile. Zeichen je Feld von -- unten nach oben: Ziegel (Z = 1, Ziffern bis 9, darüber 9Z…), Marke, -- Quader, Karol. local function karolzeile_text(a) local felder = {} for x = 1, a.dimX do local t = {} local n = a.bricks[1][x] while n > 9 do t[#t + 1] = '9' n = n - 9 end if n == 1 then t[#t + 1] = 'Z' elseif n > 1 then t[#t + 1] = tostring(n) end if a.marks[1][x] then t[#t + 1] = 'M' end if a.blocks[1][x] then t[#t + 1] = 'Q' end if a.karol.y == 0 and a.karol.x == x - 1 then t[#t + 1] = zeilenfigur[a.karol.dir] end local f = table.concat(t) if f == '' then f = '.' elseif #t > 1 then f = '{' .. f .. '}' end felder[#felder + 1] = f end return '% freie Aufgabe (target == start)\n\\karolzeile{' .. table.concat(felder, ' ') .. '}' end function M.quelltext(a, opt) opt = opt or {} local kompakt = opt.kompakt local W, L = a.dimX, a.dimY if kompakt and L == 1 and a.art == 'frei' then return karolzeile_text(a) end -- auftrag aus den Props wiedergewinnen local sleer = leer_in(a.bricks, W, L, 0) and leer_in(a.marks, W, L, false) local zleer = leer_in(a.zbricks, W, L, 0) and leer_in(a.zmarks, W, L, false) local auftrag = 'keiner' if a.art ~= 'frei' then if zleer and not sleer then auftrag = 'aufraeumen' elseif sleer and not zleer then auftrag = 'aufbauen' end end local z = {} if a.art == 'frei' then z[#z + 1] = '% freie Aufgabe (target == start)' elseif a.art == 'skript' then z[#z + 1] = '% Aufgabe mit questScript (wird in Karol Online per Skript geprüft)' end local kopf = string.format('breite=%d, laenge=%d', W, L) if a.height ~= 6 then kopf = kopf .. string.format(', hoehe=%d', a.height) end if auftrag ~= 'keiner' then kopf = kopf .. ', auftrag=' .. auftrag end z[#z + 1] = '\\begin{karolwelt}[' .. kopf .. ']' local k = a.karol if k.x >= 0 and k.x < W and k.y >= 0 and k.y < L then z[#z + 1] = string.format(' \\karol[x=%d, y=%d, richtung=%s]', k.x + 1, k.y + 1, langrichtung[k.dir]) end -- Ziegel: Schlüssel "s/z" for _, r in ipairs(rechtecke(W, L, function (x, y) local s, zz = a.bricks[y][x], a.zbricks[y][x] if s > 0 or zz > 0 then return s .. '/' .. zz end end, kompakt)) do local s, zz = r.w:match('(%d+)/(%d+)') s, zz = tonumber(s), tonumber(zz) local seite if auftrag ~= 'keiner' or s == zz then local n = auftrag == 'aufbauen' and zz or s seite = n ~= 1 and (', anzahl=' .. n) or '' elseif zz == 0 then seite = ', start' .. (s ~= 1 and (', anzahl=' .. s) or '') elseif s == 0 then seite = ', ziel' .. (zz ~= 1 and (', anzahl=' .. zz) or '') else seite = string.format(', startanzahl=%d, zielanzahl=%d', s, zz) end z[#z + 1] = ' \\ziegel[' .. ort_text(r) .. seite .. ']' end -- Marken: Schlüssel "beide" | "start" | "ziel" for _, r in ipairs(rechtecke(W, L, function (x, y) local ms, mz = a.marks[y][x], a.zmarks[y][x] if ms and mz then return 'beide' elseif ms then return 'start' elseif mz then return 'ziel' end end, kompakt)) do local seite = '' if auftrag == 'keiner' and r.w ~= 'beide' then seite = ', ' .. r.w end z[#z + 1] = ' \\marke[' .. ort_text(r) .. seite .. ']' end for _, r in ipairs(rechtecke(W, L, function (x, y) return a.blocks[y][x] or nil end, kompakt)) do z[#z + 1] = ' \\quader[' .. ort_text(r) .. ']' end z[#z + 1] = '\\end{karolwelt}' return table.concat(z, '\n') end -- --------------------------------------------------------------------- -- Anbindung an LaTeX — braucht tex, texio, kpse; erst beim -- Aufruf, damit das Modul unter texlua testbar bleibt. -- -- Lua gibt nie Steuersequenzen aus, nur Text: tex.write setzt alles -- mit Catcode 12 (Leerzeichen 10). Titel und Beschreibungen dürfen -- & % # _ \ $ ~ ^ enthalten — so kommen sie unverändert -- an, gleich unter welchem Catcode-Regime der Aufruf steht. -- --------------------------------------------------------------------- M.speicher = {} -- Name → Quest (\karoljsonlesen[name=…]) M.dateien = {} -- gefundener Pfad → Quest, false nach Fehler local T = {} M.tex = T local function tex_warnung(text) texio.write_nl('term and log', 'Package robotkarol Warning: ' .. text .. ' on input line ' .. tex.inputlineno .. '.') texio.write_nl('term and log', '') end local function tex_fehler(text, hilfe) tex.error('Package robotkarol Error: ' .. text, { hilfe or 'Siehe das Handbuch von robotkarol, Abschnitt JSON.' }) end local function lesbar(p) if lfs.isfile(p) and (not kpse.in_name_ok or kpse.in_name_ok(p)) then return p end end -- 1. wie angegeben (relativ zum Arbeitsverzeichnis), 2. relativ zur -- gerade gelesenen .tex-Datei (Dokument in einem Unterordner oder per -- \input eingebunden), 3. über kpse wie jede andere Eingabedatei. local function finden(datei) local p = lesbar(datei) if p then return p end local aktuell = status.filename local ordner = aktuell and aktuell:match('^(.*)/[^/]*$') if ordner and not datei:match('^/') then p = lesbar(ordner .. '/' .. datei) if p then return p end end return kpse.find_file(datei) end function T.laden(datei) local pfad = finden(datei) if not pfad then tex_fehler('Datei "' .. datei .. '" nicht gefunden', 'Gesucht wurde im Arbeitsverzeichnis, neben der .tex-Datei und ueber kpse.') return nil end local q = M.dateien[pfad] if q ~= nil then return q or nil -- je Lauf nur einmal parsen und melden end local f = io.open(pfad, 'rb') local text = f and f:read('a') if f then f:close() end local ok, ergebnis = pcall(M.quest_lesen, text or '') if not ok then M.dateien[pfad] = false tex_fehler(datei .. ': ' .. tostring(ergebnis):gsub('^robotkarol: ', '')) return nil end for _, w in ipairs(ergebnis.warnungen) do tex_warnung(datei .. ': ' .. w) end M.dateien[pfad] = ergebnis return ergebnis end function T.lesen(name, datei) if name == '' then name = datei:match('([^/]*)$'):gsub('%.[Jj][Ss][Oo][Nn]$', '') end local q = T.laden(datei) if q then M.speicher[name] = q end end -- Quelle: Dateiname auf .json (wird bei Bedarf geladen) oder Speichername local function quest(quelle) if quelle:match('%.[Jj][Ss][Oo][Nn]$') then return T.laden(quelle) end local q = M.speicher[quelle] if not q then tex_fehler('Keine Aufgabe namens "' .. quelle .. '" geladen', 'Vorher \\karoljsonlesen{datei.json} aufrufen oder den Dateinamen mit .json angeben.') end return q end local function aufgabe(quelle, nr) local q = quest(quelle) if not q then return nil end local n = math.tointeger(tonumber(nr)) if not n or n < 1 or n > #q.aufgaben then tex_fehler('"' .. quelle .. '" hat keine Aufgabe ' .. nr .. ' (vorhanden: ' .. #q.aufgaben .. ')') return nil end return q.aufgaben[n] end -- Zeilenumbrüche und Tabulatoren würden als Zeichen 10/9 gesetzt local function zeile(s) return (s:gsub('[\r\n\t]+', ' ')) end function T.titel(quelle) local q = quest(quelle) if q then tex.write(zeile(q.titel)) end end -- Leerzeile → Absatz; Markdown wird nicht ausgewertet function T.beschreibung(quelle) local q = quest(quelle) if not q then return end local text = q.beschreibung:gsub('\r\n?', '\n') local erster = true for absatz in (text .. '\n\n'):gmatch('(.-)\n%s*\n') do absatz = absatz:match('^%s*(.-)%s*$') if absatz ~= '' then if not erster then tex.sprint('\\par ') end tex.write(zeile(absatz)) erster = false end end end function T.aufgabentitel(quelle, nr) local a = aufgabe(quelle, nr) if a then tex.write(zeile(a.titel)) end end function T.anzahl(quelle) local q = quest(quelle) tex.write(tostring(q and #q.aufgaben or 0)) end function T.zielart(quelle, nr) local a = aufgabe(quelle, nr) if a then tex.write(a.art) end end -- Quelltextmodus: den Text einer Teilaufgabe ausgeben, zum -- Schreiben in eine Datei per \iow_now. tex.cprint(12, …) statt -- tex.write: auch die Leerzeichen bekommen Catcode 12, sonst fielen -- Einrückung und Mehrfachleerzeichen zusammen. \n ist \newlinechar. function T.quelltext(quelle, nr, kompakt, kopf) local a = aufgabe(quelle, nr) if not a then return end local text = M.quelltext(a, { kompakt = kompakt == 'true' }) if kopf == 'true' then text = '% ' .. zeile(quelle) .. ', Aufgabe ' .. nr .. ' (erzeugt von robotkarol)\n' .. text end tex.cprint(12, text) end -- Import in die Umgebung karolwelt (quelle=, \karolweltaus). -- -- Hier gibt Lua ausnahmsweise Steuersequenzen aus: die GEOMETRIE als -- Tokens, Text nie. Damit das Ergebnis nicht vom -- Catcode-Regime am Aufrufort abhängt, mit fester Catcode-Tabelle -- (\c_code_cctab, von robotkarol.sty in T.cctab hinterlegt). Die -- Befehle sind die internen Register der Umgebung — das ist dieselbe -- Zeichenlogik wie bei \ziegel und Co., nur ohne Schlüsselverarbeitung -- je Zelle (bei 100 × 100 sind das 10 000 Objekte). -- -- breite/laenge/hoehe: ausdrücklich gesetzter Wert der Umgebung oder -1. -- zustand: start | ziel | beides (für die Warnung bei freien Aufgaben). local kurzrichtung = { north = 'n', east = 'o', south = 's', west = 'w' } -- Slots des Exports: M.slots[sammlung][nr] = Teilaufgabe in Normalform M.slots = {} local function ist_slot(quelle) return quelle:find('/', 1, true) and not quelle:match('%.[Jj][Ss][Oo][Nn]$') end local function slot_zerlegen(adresse) local name, nr = adresse:match('^%s*(.-)%s*/%s*(%d+)%s*$') nr = tonumber(nr) if not name or name == '' or not nr or nr < 1 then tex_fehler('Slot-Adresse "' .. adresse .. '" ist ungültig', 'Erwartet wird sammlung/nummer, zum Beispiel blatt/1.') return nil end return name, math.tointeger(nr) end -- quelle= / erbt=: Datei, Speichername oder Slot-Adresse local function aufgabe_oder_slot(quelle, nr) if not ist_slot(quelle) then return aufgabe(quelle, nr) end local name, n = slot_zerlegen(quelle) if not name then return nil end local a = M.slots[name] and M.slots[name][n] if not a then tex_fehler('Slot "' .. quelle .. '" ist noch nicht belegt', 'Ein Slot muss weiter vorn im Dokument mit export= belegt werden.') end return a end -- Große Welten: die Grenze bleibt 100 × 100, aber ab -- etwa einer Minute Übersetzungszeit gibt es eine Warnung. Gemessen -- an t8b-dicht.json (8 600 belegte Zellen): 3D 448 s, 2D 34 s. M.WARNSCHWELLE = { ['3d'] = 1000, ['2d'] = 10000 } function T.welt_import(quelle, nr, breite, laenge, hoehe, zustand, ansicht) local a = aufgabe_oder_slot(quelle, nr) if not a then return end local ort = ist_slot(quelle) and ('Slot ' .. quelle) or (quelle .. ', Aufgabe ' .. nr) local W = breite >= 0 and breite or a.dimX local L = laenge >= 0 and laenge or a.dimY local H = hoehe >= 0 and hoehe or a.height if a.art == 'frei' and zustand == 'ziel' then tex_warnung(ort .. ' hat keine Zielwelt (freie Aufgabe); zustand=ziel zeigt die Startwelt') end local aus = {} local function put(fmt, ...) aus[#aus + 1] = string.format(fmt, ...) end put('\\int_set:Nn\\l__karol_wbreite_int{%d}', W) put('\\int_set:Nn\\l__karol_wlaenge_int{%d}', L) put('\\int_set:Nn\\l__karol_whoehe_int{%d}', H) local draussen, zuhoch, belegt = 0, 0, 0 local k = a.karol if k.x + 1 > W or k.y + 1 > L then draussen = draussen + 1 elseif k.x >= 0 and k.y >= 0 then put('\\bool_set_true:N\\l__karol_wkarol_bool' .. '\\int_set:Nn\\l__karol_wkx_int{%d}\\int_set:Nn\\l__karol_wky_int{%d}' .. '\\tl_set:Nn\\l__karol_wkdir_tl{%s}', k.x + 1, k.y + 1, kurzrichtung[k.dir]) end for y = 1, a.dimY do for x = 1, a.dimX do local s, z = a.bricks[y][x], a.zbricks[y][x] local ms, mz, q = a.marks[y][x], a.zmarks[y][x], a.blocks[y][x] if s > 0 or z > 0 or ms or mz or q then if x > W or y > L then draussen = draussen + 1 else belegt = belegt + 1 if math.max(s, z) > H then zuhoch = zuhoch + 1 end -- "x,y" ist der Prop-Schlüssel der Umgebung (\__karol_wbereich:Nn) if s > 0 then put('\\prop_put:Nnn\\l__karol_wziegel_prop{%d,%d}{%d}', x, y, s) end if z > 0 then put('\\prop_put:Nnn\\l__karol_wzziegel_prop{%d,%d}{%d}', x, y, z) end if ms then put('\\prop_put:Nnn\\l__karol_wmarke_prop{%d,%d}{karolWMarke}', x, y) end if mz then put('\\prop_put:Nnn\\l__karol_wzmarke_prop{%d,%d}{karolWMarke}', x, y) end if q then put('\\prop_put:Nnn\\l__karol_wquader_prop{%d,%d}{1}', x, y) end end end end end if draussen > 0 then tex_warnung(string.format('%s: %d Objekt(e) liegen außerhalb von breite=%d, laenge=%d' .. ' und werden nicht gezeichnet (die Datei hat %d×%d)', ort, draussen, W, L, a.dimX, a.dimY)) end local schwelle = M.WARNSCHWELLE[ansicht] if schwelle and belegt > schwelle then tex_warnung(string.format('%s: %d belegte Zellen — die Übersetzung in %s dauert' .. ' etwa %d s%s', ort, belegt, ansicht, math.floor(belegt * (ansicht == '3d' and 0.052 or 0.004)), ansicht == '3d' and '; ansicht=2d ist rund zehnmal schneller' or '')) end if zuhoch > 0 then tex_warnung(string.format('%s: %d Stapel sind höher als hoehe=%d', ort, zuhoch, H)) end tex.sprint(T.cctab, table.concat(aus)) end -- --------------------------------------------------------------------- -- Export (export=, \karoljsonschreiben) -- --------------------------------------------------------------------- -- Im Endcode einer karolwelt mit export= reicht die .sty die Props -- Eintrag für Eintrag herein (export_beginn, export_zelle) und schließt -- mit export_ende. Geschrieben wird erst am Dokumentende. local offen -- Slot im Aufbau local auftraege = {} -- \karoljsonschreiben, in Aufrufreihenfolge function T.export_beginn(adresse) local name, nr = slot_zerlegen(adresse) offen = name and { name = name, nr = nr, adresse = adresse, s = {}, z = {}, ms = {}, mz = {}, q = {} } or nil end function T.export_zelle(ebene, schluessel, wert) if not offen then return end local x, y = schluessel:match('^(%-?%d+),(%-?%d+)$') local liste = offen[ebene] liste[#liste + 1] = { tonumber(x), tonumber(y), math.tointeger(tonumber(wert)) or 1 } end -- Text aus dem LaTeX-Quelltext (titel=, beschreibung=, aufgabentitel=) -- kommt als \tl_to_str: \% \& \# \_ \$ \{ \} sind die Maskierungen, -- die man dort tippen muss, und # erscheint verdoppelt. In der Datei -- sollen die Zeichen selbst stehen. local function aus_latex(text) return (text:gsub('##', '#'):gsub('\\([%%&#_%$%{%} ])', '%1')) end M.aus_latex = aus_latex local function tief_gleich(a, b) if type(a) ~= 'table' or type(b) ~= 'table' then return a == b end for k, v in pairs(a) do if not tief_gleich(v, b[k]) then return false end end for k in pairs(b) do if a[k] == nil then return false end end return true end local function geometrie_gleich(a, b) return a.dimX == b.dimX and a.dimY == b.dimY and a.height == b.height and tief_gleich(a.karol, b.karol) and tief_gleich(a.bricks, b.bricks) and tief_gleich(a.zbricks, b.zbricks) and tief_gleich(a.marks, b.marks) and tief_gleich(a.zmarks, b.zmarks) and tief_gleich(a.blocks, b.blocks) end -- hatkarol/seiten: 'true' | 'false'; quelle/qnr: woher die Umgebung importiert hat function T.export_ende(W, L, H, hatkarol, kx, ky, kdir, auftrag, seiten, titel, quelle, qnr) local o = offen offen = nil if not o then return end local ort = 'Slot ' .. o.adresse if H < 0 then -- \\karolzeile: deren hoehe ist nur die Rahmenhöhe der Zeichnung H = 6 for _, e in ipairs(o.s) do H = math.max(H, e[3]) end end if W < 1 or L < 1 or W > M.GRENZE_XY or L > M.GRENZE_XY then tex_fehler(string.format('%s: Welt %d×%d lässt sich nicht exportieren (1 bis %d)', ort, W, L, M.GRENZE_XY)) return end local a = { dimX = W, dimY = L, height = H, titel = aus_latex(titel), bricks = M.matrix(W, L, 0), zbricks = M.matrix(W, L, 0), marks = M.matrix(W, L, false), zmarks = M.matrix(W, L, false), blocks = M.matrix(W, L, false), } local draussen = 0 local function eintragen(liste, m, wert) for _, e in ipairs(liste) do local x, y = e[1], e[2] if x and y and x >= 1 and y >= 1 and x <= W and y <= L then m[y][x] = wert == nil and e[3] or wert else draussen = draussen + 1 end end end eintragen(o.s, a.bricks) eintragen(o.z, a.zbricks) eintragen(o.ms, a.marks, true) eintragen(o.mz, a.zmarks, true) eintragen(o.q, a.blocks, true) if draussen > 0 then tex_warnung(string.format('%s: %d Einträge liegen außerhalb der Welt und fehlen im Export', ort, draussen)) end local richtung = { o = 'east', w = 'west', n = 'north', s = 'south' } if hatkarol == 'true' then a.karol = { x = kx - 1, y = ky - 1, dir = richtung[kdir] or 'south' } else a.karol = { x = 0, y = 0, dir = 'south' } tex_warnung(ort .. ': die Welt enthält keinen \\karol; exportiert wird Karol auf' .. ' x=1, y=1 mit Blick nach Süden (anders: exportkarol={x=…, y=…, richtung=…})') end -- unverändert aus einer Datei übernommen? Dann bleiben Zielkarol und -- Ziel-Quader der Datei erhalten, ebenso ihr Titel. if quelle ~= '' then local src = aufgabe_oder_slot(quelle, qnr) if src then if a.titel == '' then a.titel = src.titel end if geometrie_gleich(a, src) then a.zielkarol, a.zblocks = src.zielkarol, src.zblocks end end end if a.titel == '' then a.titel = 'Aufgabe ' .. o.nr end -- Kodierung: Zielwelt == Startwelt (ohne Karol) heißt in Karol Online frei local zw = { dimX = W, dimY = L, height = H, bricks = a.zbricks, marks = a.zmarks, blocks = a.zblocks or a.blocks } a.art = M.weltenGleich(a, zw) and 'frei' or 'welt' if a.art == 'frei' and (auftrag ~= 'keiner' or seiten == 'true') then tex_warnung(ort .. ': Start- und Zielwelt sind gleich, obwohl auftrag= oder' .. ' start/ziel-Angaben benutzt werden — in Karol Online wird das eine freie' .. ' Aufgabe ohne Prüfung') end -- dieselben semantischen Prüfungen wie beim Lesen local warnungen = {} local probe = M.welt_json(W, L, H, a.karol, a.bricks, a.marks, a.blocks) M.welt_lesen(M.json_lesen(M.json_schreiben(probe)), ort .. ', Startwelt', warnungen) probe = M.welt_json(W, L, H, a.karol, a.zbricks, a.zmarks, a.blocks) M.welt_lesen(M.json_lesen(M.json_schreiben(probe)), ort .. ', Zielwelt', warnungen) local gesehen = {} for _, w in ipairs(warnungen) do -- Karol-Befunde stehen sonst für Start und Ziel doppelt da local kern = w:gsub(', Startwelt', ''):gsub(', Zielwelt', '') if not (w:find('Karol', 1, true) and gesehen[kern]) then tex_warnung(w) end gesehen[kern] = true end M.slots[o.name] = M.slots[o.name] or {} local alt = M.slots[o.name][o.nr] if alt then -- karolweltpaar führt seinen Rumpf zweimal aus: gleicher Inhalt ist still if not (geometrie_gleich(alt, a) and alt.titel == a.titel) then tex_fehler(ort .. ' ist schon mit einem anderen Inhalt belegt', 'Jeder Slot darf nur einmal belegt werden (außer mit identischem Inhalt).') end return end M.slots[o.name][o.nr] = a end function T.schreiben_vormerken(sammlung, datei, titel, titel_da, beschreibung, beschr_da, programm, sprache) if not datei:match('%.[Jj][Ss][Oo][Nn]$') then datei = datei .. '.json' end auftraege[#auftraege + 1] = { sammlung = sammlung, datei = datei, titel = titel_da == 'true' and aus_latex(titel) or nil, beschreibung = beschr_da == 'true' and aus_latex(beschreibung) or nil, programm = programm ~= '' and programm or nil, sprache = sprache ~= '' and sprache or 'blocks', } end -- Kann TeX die Datei schreiben? \iow_open bricht sonst FATAL ab („I -- can't write on file"), und das PDF entsteht nicht. openout_any = p -- verbietet .., absolute Pfade und Punktdateien; ein fehlender Ordner -- wird nicht angelegt. Gibt nil oder den Grund zurück. local function nicht_schreibbar(datei) if kpse.out_name_ok and not kpse.out_name_ok(datei) then return 'TeX darf dorthin nicht schreiben (openout_any: kein "..", kein absoluter Pfad,' .. ' keine Datei mit Punkt am Anfang)' end local ordner = datei:match('^(.*)/[^/]*$') if ordner and ordner ~= '' then local basis = status.output_directory if basis and basis ~= '' and not ordner:match('^/') then ordner = basis .. '/' .. ordner end if not lfs.isdir(ordner) then return 'der Ordner "' .. ordner .. '" existiert nicht (TeX legt keine Ordner an)' end end end -- Startprogramm aus einer Textdatei: Zeilenenden vereinheitlicht, Leerraum -- am Ende entfernt (wie Karol Online speichert). nil nach einem Fehler. local function programm_lesen(datei) local pfad = finden(datei) local f = pfad and io.open(pfad, 'rb') if not f then tex_fehler('Startprogramm "' .. datei .. '" nicht gefunden', 'Gesucht wurde im Arbeitsverzeichnis, neben der .tex-Datei und ueber kpse.') return nil end local text = f:read('a') f:close() return (text:gsub('^\239\187\191', ''):gsub('\r\n?', '\n'):gsub('%s+$', '')) end -- Am Dokumentende: alle Aufträge in Text verwandeln. Gibt die Zahl der -- fertigen Dateien aus; T.ausgabe_datei/T.ausgabe_text liefern sie. local ausgaben = {} function T.ausgaben_bauen() ausgaben = {} local geschrieben = {} for _, auf in ipairs(auftraege) do geschrieben[auf.sammlung] = true local basis = M.speicher[auf.sammlung] local slots = M.slots[auf.sammlung] or {} local n = basis and #basis.aufgaben or 0 for nr in pairs(slots) do n = math.max(n, nr) end local liste, luecken = {}, {} for i = 1, n do liste[i] = slots[i] or (basis and basis.aufgaben[i]) if not liste[i] then luecken[#luecken + 1] = tostring(i) end end if n == 0 then tex_fehler('Sammlung "' .. auf.sammlung .. '" ist leer; ' .. auf.datei .. ' wird nicht geschrieben', 'Welten mit export=' .. auf.sammlung .. '/1 usw. belegen die Sammlung.') elseif #luecken > 0 then tex_fehler('Sammlung "' .. auf.sammlung .. '": Slot ' .. table.concat(luecken, ', ') .. ' fehlt; ' .. auf.datei .. ' wird nicht geschrieben', 'Karol Online kennt keine Lücken in der Aufgabenliste.') elseif nicht_schreibbar(auf.datei) then tex_fehler(auf.datei .. ' wird nicht geschrieben: ' .. nicht_schreibbar(auf.datei), 'Die Datei muss im Ordner des Dokuments oder in einem vorhandenen Unterordner liegen.') else local roh = basis and basis.roh or M.objekt('lng', 'de', 'questScript', '') if auf.programm then local text = programm_lesen(auf.programm) -- Kopie, damit eine zweite Datei derselben Sammlung es nicht erbt local paare = {} for _, k in ipairs(M.schluessel(roh)) do paare[#paare + 1] = k paare[#paare + 1] = roh[k] end if text then roh = M.objekt(table.unpack(paare)) roh.program = text roh.language = auf.sprache end end local quest = { roh = roh, titel = auf.titel or (basis and basis.titel) or auf.sammlung, beschreibung = auf.beschreibung or (basis and basis.beschreibung) or '', aufgaben = liste, } ausgaben[#ausgaben + 1] = { datei = auf.datei, text = M.quest_schreiben(quest) } end end for name in pairs(M.slots) do if not geschrieben[name] then tex_warnung('Sammlung "' .. name .. '" hat Slots, wird aber nie geschrieben' .. ' (\\karoljsonschreiben fehlt)') end end tex.write(tostring(#ausgaben)) end function T.ausgabe_datei(i) tex.cprint(12, ausgaben[i].datei) end function T.ausgabe_text(i) tex.cprint(12, ausgaben[i].text) end return M