All files / js missives.js

81.19% Statements 488/601
63.12% Branches 89/141
80.95% Functions 17/21
81.19% Lines 488/601

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 60271x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 1x 1x 1x 1x 1x 1x 1x 50x 50x 50x 50x 50x 200x 150x 100x 50x 200x 50x 50x 50x 50x 50x 50x 50x 50x 1x 1x 1x 71x 71x 71x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 10x 1x 1x 1x 10x 1x 1x 1x 1x           1x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 71x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 71x       71x 71x 71x 71x 71x 3x 3x 3x 3x 3x 3x 3x 71x 3x 3x 3x 3x 3x 9x 9x 83x 9x 9x 3x 3x 3x 71x 83x 83x 83x 83x 5x 5x 5x 5x 5x 5x 5x 4x 4x 4x 1x 1x 5x 5x 78x 78x 78x 83x 78x 83x 71x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 71x 3x 3x 2x 2x 2x               2x 2x 2x 1x 1x 1x 1x 1x 1x 2x     1x 1x   3x 3x 71x 71x 71x 71x 71x 71x 2x 2x 2x 2x 2x 2x 2x 2x 2x 20x 13x 13x 20x 20x 20x 20x 20x 101x 101x 13x 101x 85x 88x 3x 3x 3x 3x 101x 13x 13x 13x 13x 20x 2x 2x 42x 14x 14x 14x 42x 4x 4x 4x 4x 4x 4x 4x 28x 5x 5x 5x 5x 5x 5x           24x 19x 19x 19x 19x 42x 2x 2x 2x 2x 2x 30x 24x 24x 2x 2x 2x 2x 71x 71x 71x 2238x 2238x 24x 24x 24x 24x 24x 2238x 2238x 71x 71x 71x 71x 71x 71x 71x 71x 4948x 4948x 4948x 4948x 4948x 4948x 4948x 2237x 3541x 2237x 2237x 2237x 3541x 4948x 4948x 1x 1x 1x 1x 4948x 71x 71x 1x 1x 1x 1x 4x 4x 4x 1x 1x 1x 4x   1x 71x 71x 1x 1x 1x 1x 1x 3x 9x 9x 8x 8x 8x 3x 3x 3x 8x 9x 3x 1x         1x 1x 71x 71x 71x 71x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x         1x 1x 1x 1x       1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x     1x 1x 1x 1x 1x 1x 1x 71x 71x 71x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 71x 71x 71x 1x 1x 1x 1x 1x 71x 71x 71x 71x 71x                               71x 71x 71x 71x 71x 71x 71x 71x 71x                                                                                                                 71x 71x            
// missives.js — Mail daemon missive selection, template expansion, and delivery.
// C ref: mail.c — ckmailstatus(), newmail(), readmail()
//
// Selects a random missive from the encrypted corpus, expands templates
// with player info and random choices, and delivers as a scroll of mail.
 
import { game } from './gstate.js';
import { silent_rn2, rn2 } from './rng.js';
import { MISSIVES_CORPUS } from './missives_data.js';
import { vfsWriteFile, vfsReadFile } from './storage.js';
import { pline, verbalize } from './pline.js';
import { mksobj } from './mkobj.js';
import { SCR_MAIL } from './objects.js';
import { PM_MAIL_DAEMON } from './monsters.js';
import { mons } from './monsters.js';
import { makemon } from './makemon.js';
import { oname } from './do_name.js';
import { hold_another_object } from './invent.js';
import { mongone, m_next2u } from './mon.js';
import { place_monster, remove_monster } from './steed.js';
import { newsym, flush_screen, delay_output } from './display.js';
import { m_at } from './map_access.js';
import { accessible } from './monmove.js';
import { enexto } from './makemon.js';
import { dist2 } from './hacklib.js';
import { isok, NO_MM_FLAGS, ONAME_NO_FLAGS } from './const.js';
 
const SEPARATOR = '===';
 
// --- Corpus parsing ---
 
// Parse the corpus into an array of { id, from, subject, body, start, end }
// where start/end are character offsets into the corpus string.
let _parsedMissives = null;
 
function parseMissives() {
    if (_parsedMissives) return _parsedMissives;
    const corpus = MISSIVES_CORPUS;
    const entries = [];
    const parts = corpus.split('\n' + SEPARATOR + '\n');
    let offset = 0;
    for (const part of parts) {
        const trimmed = part.replace(/^===\n/, ''); // first entry may start with ===
        if (!trimmed.trim()) { offset += part.length + 4; continue; }
        const lines = trimmed.split('\n');
        let id = '', from = '', subject = '', bodyStart = 0;
        for (let i = 0; i < lines.length; i++) {
            if (lines[i].startsWith('Id: ')) id = lines[i].slice(4).trim();
            else if (lines[i].startsWith('From: ')) from = lines[i].slice(6).trim();
            else if (lines[i].startsWith('Subject: ')) subject = lines[i].slice(9).trim();
            else if (lines[i].trim() === '' && id) { bodyStart = i + 1; break; }
        }
        const body = lines.slice(bodyStart).join('\n').trim();
        entries.push({
            id, from, subject, body,
            start: offset,
            end: offset + part.length,
        });
        offset += part.length + 4; // +4 for \n===\n
    }
    _parsedMissives = entries;
    return entries;
}
 
// Select a missive by random byte offset — longer missives are more likely.
// Skips missives the player has already received (by id).
function selectMissive(receivedIds) {
    const corpus = MISSIVES_CORPUS;
    const entries = parseMissives();
    if (entries.length === 0) return null;
 
    // Try up to 20 times to find an unseen missive
    for (let attempt = 0; attempt < 20; attempt++) {
        const byteOffset = rn2(corpus.length);
        // Find which entry contains this offset
        let selected = null;
        for (const entry of entries) {
            if (byteOffset >= entry.start && byteOffset < entry.end) {
                selected = entry;
                break;
            }
        }
        if (!selected) continue;
        if (receivedIds.has(selected.id)) continue;
        return selected;
    }

    // Fallback: pick any unseen missive uniformly
    const unseen = entries.filter(e => !receivedIds.has(e.id));
    if (unseen.length === 0) return null;
    return unseen[rn2(unseen.length)];
}
 
// --- Template expansion ---
 
// NetHack shopkeeper names (subset)
const SHOPKEEPER_NAMES = [
    'Asidonhopo', 'Akstransen', 'Djordansen', 'Izchak',
    'Ansen', 'Dansen', 'Ansen', 'Havansen',
    'Ansen', 'Bansen', 'Ansen', 'Cansen',
    'Xixor', 'Transen', 'Ansen', 'Flansen',
    'Sansen', 'Kansen', 'Wansen', 'Lansen',
    'Gansen', 'Nansen', 'Mansen', 'Pansen',
];
 
function getPlayerInfo() {
    const g = game;
    const u = g?.u;
    const flags = g?.flags;
    // Role/race/alignment/gender names
    const roles = ['Archeologist','Barbarian','Caveman','Healer','Knight',
        'Monk','Priest','Ranger','Rogue','Samurai','Tourist','Valkyrie','Wizard'];
    const races = ['human','elf','dwarf','gnome','orc'];
    const aligns = ['chaotic','neutral','lawful'];
    const genders = ['male','female'];
 
    const roleIdx = flags?.initrole ?? -1;
    const raceIdx = flags?.initrace ?? -1;
    const alignIdx = flags?.initalign ?? -1;
    const gendIdx = flags?.initgend ?? -1;
 
    const roleName = (roleIdx >= 0 && roleIdx < roles.length) ? roles[roleIdx] : 'Adventurer';
    const raceName = (raceIdx >= 0 && raceIdx < races.length) ? races[raceIdx] : 'human';
    const alignName = (alignIdx >= 0 && alignIdx < 3) ? aligns[alignIdx] : 'neutral';
    const gendName = (gendIdx >= 0 && gendIdx < 2) ? genders[gendIdx] : 'male';
 
    // God name from the game's deity list
    const godName = g?.align?.god || 'your god';
    const playerName = g?.plname || u?.name || 'Adventurer';
 
    return {
        name: playerName,
        role: roleName.toLowerCase(),
        Role: roleName,
        race: raceName.toLowerCase(),
        Race: raceName.charAt(0).toUpperCase() + raceName.slice(1),
        align: alignName,
        Align: alignName.charAt(0).toUpperCase() + alignName.slice(1),
        gender: gendName,
        god: godName.toLowerCase(),
        God: godName.charAt(0).toUpperCase() + godName.slice(1).toLowerCase(),
        he: gendIdx === 1 ? 'she' : 'he',
        He: gendIdx === 1 ? 'She' : 'He',
        him: gendIdx === 1 ? 'her' : 'him',
        his: gendIdx === 1 ? 'her' : 'his',
    };
}
 
function anPrefix(word) {
    return /^[aeiou]/i.test(word) ? 'an' : 'a';
}
 
// Expand all template syntax in a string.
// Handles: {name}, {role}, {Role}, {an:role}, {An:role}, {rand:N-M},
//          {shopkeeper}, [a|b|c], [:if:role:text], [:if:role:yes|no],
//          [:if:role1/role2:text]
function expandTemplate(text, info) {
    // First expand [...] brackets (may be nested)
    text = expandBrackets(text, info);
    // Then expand {var} substitutions
    text = expandVars(text, info);
    return text;
}
 
function expandBrackets(text, info) {
    // Process innermost brackets first, working outward
    let prev;
    let iterations = 0;
    do {
        prev = text;
        text = text.replace(/\[([^\[\]]*)\]/g, (match, content) => {
            return resolveBracket(content, info);
        });
        iterations++;
    } while (text !== prev && iterations < 50);
    return text;
}
 
function resolveBracket(content, info) {
    // Conditional: [:if:condition:thenText] or [:if:condition:thenText|elseText]
    const ifMatch = content.match(/^:if:([^:]+):([\s\S]*)$/);
    if (ifMatch) {
        const conditions = ifMatch[1].split('/');
        const rest = ifMatch[2];
        // Split on | for then/else — but only the LAST | is the else separator
        // (earlier | are part of nested picks that should already be resolved)
        const lastPipe = rest.lastIndexOf('|');
        const matches = conditions.some(c => testCondition(c.trim(), info));
        if (lastPipe === -1) {
            // No else clause
            return matches ? rest : '';
        }
        const thenText = rest.slice(0, lastPipe);
        const elseText = rest.slice(lastPipe + 1);
        return matches ? thenText : elseText;
    }
 
    // Random pick: split on |, choose one
    const options = content.split('|');
    if (options.length === 1) return content;
    return options[rn2(options.length)];
}
 
function testCondition(cond, info) {
    // Role check
    if (info.role.toLowerCase() === cond.toLowerCase()) return true;
    if (info.Role.toLowerCase() === cond.toLowerCase()) return true;
    // Race check
    if (info.race.toLowerCase() === cond.toLowerCase()) return true;
    // Alignment check
    if (info.align.toLowerCase() === cond.toLowerCase()) return true;
    // Gender check
    if (info.gender.toLowerCase() === cond.toLowerCase()) return true;
    return false;
}
 
function expandVars(text, info) {
    return text.replace(/\{([^}]+)\}/g, (match, key) => {
        // {an:role}, {An:role}, {an:race}, {An:race}
        const anMatch = key.match(/^(an|An):(\w+)$/);
        if (anMatch) {
            const capitalize = anMatch[1] === 'An';
            const varName = anMatch[2];
            const val = info[varName] || info[varName.toLowerCase()] || varName;
            const prefix = anPrefix(val);
            const result = prefix + ' ' + val;
            return capitalize ? result.charAt(0).toUpperCase() + result.slice(1) : result;
        }
        // {rand:N-M}
        const randMatch = key.match(/^rand:(\d+)-(\d+)$/);
        if (randMatch) {
            const lo = parseInt(randMatch[1]);
            const hi = parseInt(randMatch[2]);
            if (hi <= lo) return String(lo);
            return String(lo + rn2(hi - lo + 1));
        }
        // {shopkeeper}
        if (key === 'shopkeeper') {
            return SHOPKEEPER_NAMES[rn2(SHOPKEEPER_NAMES.length)];
        }
        // Direct variable lookup
        if (key in info) return info[key];
        return match; // leave unrecognized vars as-is
    });
}
 
// --- Text rewrapping ---
 
// Rewrap expanded text to fit ~75 columns.
// Preserves lines starting with spaces (lists, headers, ASCII art).
// Preserves blank lines as paragraph breaks.
function rewrap(text, cols = 75) {
    const lines = text.split('\n');
    const result = [];
    let para = [];
 
    let paraIndent = '';    // prefix for first line
    let paraHangIndent = ''; // prefix for continuation lines
 
    function flushPara() {
        if (para.length === 0) return;
        const joined = para.join(' ').replace(/\s+/g, ' ').trim();
        const firstPrefix = paraIndent;
        const contPrefix = paraHangIndent || paraIndent;
        const words = joined.split(' ');
        let line = '';
        let isFirst = true;
        for (const word of words) {
            const prefix = isFirst ? firstPrefix : contPrefix;
            if (line.length === 0) {
                line = word;
            } else if (prefix.length + line.length + 1 + word.length <= cols) {
                line += ' ' + word;
            } else {
                result.push((isFirst ? firstPrefix : contPrefix) + line);
                isFirst = false;
                line = word;
            }
        }
        if (line) result.push((isFirst ? firstPrefix : contPrefix) + line);
        para = [];
        paraIndent = '';
        paraHangIndent = '';
    }
 
    for (const line of lines) {
        if (line.trim() === '') {
            // Blank line = paragraph break
            flushPara();
            result.push('');
        } else if (/^\s+[-*]\s/.test(line)) {
            // Bulleted list item — starts a new indented paragraph
            // Hanging indent aligns to the text after the bullet
            flushPara();
            const m = line.match(/^(\s+[-*]\s+)/);
            paraIndent = line.match(/^(\s+)/)[1];
            paraHangIndent = ' '.repeat(m[1].length);
            para.push(line.slice(paraIndent.length));
        } else if (/^\s/.test(line)) {
            // Indented continuation — join to current indented paragraph
            const indent = line.match(/^(\s+)/)[1];
            if (para.length > 0 && paraIndent) {
                // Continue the current indented/bullet paragraph
                para.push(line.trim());
            } else {
                // Start a new indented paragraph
                if (paraIndent !== indent) flushPara();
                paraIndent = indent;
                para.push(line.trim());
            }
        } else {
            // Normal text — accumulate into paragraph for rewrapping
            if (paraIndent) flushPara();
            para.push(line);
        }
    }
    flushPara();
 
    // Collapse multiple consecutive blank lines into one
    const collapsed = [];
    for (let i = 0; i < result.length; i++) {
        if (result[i] === '' && i > 0 && collapsed[collapsed.length - 1] === '') continue;
        collapsed.push(result[i]);
    }
    // Trim trailing blank lines
    while (collapsed.length > 0 && collapsed[collapsed.length - 1] === '') collapsed.pop();
    return collapsed.join('\n');
}
 
// --- Mail daemon state ---
 
function getMailState() {
    if (!game._mailState) {
        game._mailState = {
            receivedIds: new Set(),
            hasReceivedAny: false,
        };
    }
    return game._mailState;
}
 
// --- Public API ---
 
// Check whether to deliver mail this turn.
// Called from moveloop_core at the same point C calls ckmailstatus().
// Uses silent_rn2 (peek) for the probability check — does not
// perturb the RNG stream. Only draws from RNG if delivery triggers.
export async function ckmailstatus() {
    const g = game;
    if (!g || !g.u) return;
    // C ref: mail.c — skip if swallowed or mail disabled
    if (g.u.uswallow) return;
    if (g.flags && g.flags.biff === false) return;
    // Only deliver beyond dungeon level 1
    if (!g.u.uz || (g.u.uz.dnum === 0 && g.u.uz.dlevel <= 1)) return;
    // Not during multi-turn actions
    if (g.multi) return;
 
    const state = getMailState();
    // Cap at 10 deliveries per game
    if (state.receivedIds.size >= 10) return;
    const prob = state.hasReceivedAny ? 8000 : 2000;
    if (silent_rn2(prob) !== 80) return;
 
    // Delivery triggered! Now we draw on RNG freely.
    // Return the promise so the caller can await if needed.
    return await deliverMail();
}
 
// C ref: mail.c:236 — find starting position for mail daemon
function md_start(startp) {
    const g = game;
    // Try to find a position at the edge of the map near the hero
    for (let trycount = 0; trycount < 20; trycount++) {
        const x = rn2(g.level?.cols || 80);
        const y = rn2(g.level?.rows || 21);
        if (isok(x, y) && accessible(x, y) && !m_at(x, y)) {
            startp.x = x; startp.y = y;
            return true;
        }
    }
    return false;
}
 
// C ref: mail.c:247 — find stopping position adjacent to hero
function md_stop(stopp, startp) {
    const g = game;
    const u = g.u;
    let min_distance = -1;
    for (let x = u.ux - 1; x <= u.ux + 1; x++) {
        for (let y = u.uy - 1; y <= u.uy + 1; y++) {
            if (!isok(x, y) || (x === u.ux && y === u.uy)) continue;
            if (accessible(x, y) && !m_at(x, y)) {
                const distance = dist2(x, y, startp.x, startp.y);
                if (min_distance < 0 || distance < min_distance
                    || (distance === min_distance && rn2(2))) {
                    stopp.x = x; stopp.y = y;
                    min_distance = distance;
                }
            }
        }
    }
    if (min_distance < 0) {
        const cc = { x: 0, y: 0 };
        if (!enexto(cc, u.ux, u.uy, mons[PM_MAIL_DAEMON])) return false;
        stopp.x = cc.x; stopp.y = cc.y;
    }
    return true;
}
 
// C ref: mail.c:277
const mail_text = ['Gangway!', 'Look out!', 'Pardon me!'];
 
async function deliverMail() {
    const state = getMailState();
    const info = getPlayerInfo();
    const missive = selectMissive(state.receivedIds);
    if (!missive) return;
 
    state.receivedIds.add(missive.id);
    state.hasReceivedAny = true;
 
    // Expand templates
    const expandedSubject = rewrap(expandTemplate(missive.subject, info), 75);
    const expandedFrom = expandTemplate(missive.from, info);
    const expandedBody = rewrap(expandTemplate(missive.body, info), 75);
    const fullEmail = `From: ${expandedFrom}\nSubject: ${expandedSubject}\n\n${expandedBody}`;
 
    const senderMatch = expandedFrom.match(/^"?([^"<]+)/);
    const senderName = senderMatch ? senderMatch[1].trim() : 'someone';
 
    // Store for scroll reading
    game._pendingMail = {
        id: missive.id,
        from: expandedFrom,
        senderName,
        subject: expandedSubject,
        body: expandedBody,
        fullText: fullEmail,
    };
 
    // Append to shell VFS mailbox
    appendToVfsMailbox(fullEmail);
 
    // C ref: mail.c:399-450 — newmail()
    // Try to spawn and animate the mail daemon
    const start = { x: 0, y: 0 };
    const stop = { x: 0, y: 0 };
    if (!md_start(start) || !md_stop(stop, start)) {
        // Can't find a path — just pline the delivery
        await pline(`You have some mail from ${senderName}.`);
        return;
    }
 
    // Make the daemon
    const md = await makemon(mons[PM_MAIL_DAEMON], start.x, start.y, NO_MM_FLAGS);
    if (!md) {
        await pline(`You have some mail from ${senderName}.`);
        return;
    }
 
    // Move daemon to stop position (simplified — just teleport, no rush animation)
    remove_monster(md.mx, md.my);
    place_monster(md, stop.x, stop.y);
    newsym(stop.x, stop.y);
    await flush_screen(0);
    await delay_output();
 
    // C ref: mail.c:417 — "Hello, player! I have some mail for you."
    await verbalize(`${mail_text[rn2(3)]} ${game.plname}! I have some mail for you.`);
 
    // Create scroll of mail
    const obj = mksobj(SCR_MAIL, false, false);
    // Name the scroll with the sender
    oname(obj, `mail from ${senderName}`, ONAME_NO_FLAGS);
 
    // Deliver the scroll
    if (!m_next2u(md)) {
        await verbalize('Catch!');
    }
    await hold_another_object(obj, 'Oops!', null, null);
 
    // Daemon leaves — remove it
    remove_monster(md.mx, md.my);
    newsym(md.mx, md.my);
    mongone(md);
}
 
const VFS_MAILBOX = 'home/Mail/inbox';
 
function appendToVfsMailbox(emailText) {
    const existing = vfsReadFile(VFS_MAILBOX) || '';
    const now = new Date();
    const dateStr = now.toLocaleString('en-US', {
        weekday: 'short', month: 'short', day: 'numeric',
        hour: '2-digit', minute: '2-digit', hour12: false,
    });
    const dated = `Date: ${dateStr}\n${emailText}`;
    const separator = existing ? '\n\n---\n\n' : '';
    vfsWriteFile(VFS_MAILBOX, existing + separator + dated);
}
 
// Read the pending mail (called when player reads scroll of mail).
export function readMail() {
    const mail = game._pendingMail;
    if (!mail) return null;
    game._pendingMail = null;
    return mail;
}
 
// --- Shell mailbox API ---
 
// Get parsed messages from the VFS mailbox.
export function getMailboxMessages() {
    const content = vfsReadFile(VFS_MAILBOX);
    if (!content || !content.trim()) return [];
    return content.split(/\n---\n/).map(m => m.trim()).filter(Boolean).map(raw => {
        const lines = raw.split('\n');
        let from = '', subject = '', date = '';
        let bodyStart = 0;
        for (let i = 0; i < lines.length; i++) {
            if (lines[i].startsWith('From: ')) from = lines[i].slice(6).replace(/<[^>]+>/, '').trim();
            else if (lines[i].startsWith('Subject: ')) subject = lines[i].slice(9).trim();
            else if (lines[i].startsWith('Date: ')) date = lines[i].slice(6).trim();
            else if (lines[i].trim() === '') { bodyStart = i + 1; break; }
        }
        return { from, subject, date, body: lines.slice(bodyStart).join('\n'), raw };
    });
}
 
// Seed the shell mailbox with a few pre-existing messages on first access.
// Called by the shell `mail` command (or at shell login) to populate the
// inbox with messages that appear to have arrived before the player started.
// Uses a simple PRNG seeded from the current time so different sessions
// get different seed mails, but the same session is consistent.
const VFS_MAIL_SEEDED = 'home/Mail/.seeded';
 
export function seedMailboxIfNeeded() {
    if (vfsReadFile(VFS_MAIL_SEEDED)) return; // already seeded
    const entries = parseMissives();
    if (entries.length === 0) return;

    // Pick 2-4 seed messages — use wall clock as simple seed
    const now = Date.now();
    const count = 2 + (now % 3); // 2, 3, or 4
    const seenIds = new Set();
    const fakeInfo = {
        name: 'Adventurer', role: 'adventurer', Role: 'Adventurer',
        race: 'human', Race: 'Human', align: 'neutral', Align: 'Neutral',
        gender: 'male', god: 'your god', God: 'Your God',
        he: 'they', He: 'They', him: 'them', his: 'their',
    };

    // Pick from the "safe" emails — ones that don't depend heavily on
    // game state (avoid quest leader, bones ghost, god review, etc.)
    const safeIds = new Set([
        'izchak-lighting', 'oracle-consultation', 'gue-gazette',
        'gygax-memorial', 'crowther-woods', 'jay-fenlason',
        'toy-wichman-postcard', 'devteam-autoreply', 'brian-kernighan',
        'ken-arnold', 'richard-stallman', 'elbereth-ad',
        'shopkeeper-guild-newsletter', 'quantum-mechanic',
        'astral-temple-fundraising', 'twoflower-tour',
    ]);
    const safeEntries = entries.filter(e => safeIds.has(e.id));
    if (safeEntries.length === 0) return;

    // Simple deterministic shuffle based on timestamp
    const shuffled = [...safeEntries];
    for (let i = shuffled.length - 1; i > 0; i--) {
        const j = ((now >> (i % 16)) + i * 7) % (i + 1);
        [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
    }

    // Generate fake past dates (1-14 days ago)
    for (let i = 0; i < Math.min(count, shuffled.length); i++) {
        const missive = shuffled[i];
        const expandedSubject = rewrap(expandTemplate(missive.subject, fakeInfo), 75);
        const expandedFrom = expandTemplate(missive.from, fakeInfo);
        const expandedBody = rewrap(expandTemplate(missive.body, fakeInfo), 75);
        const fullEmail = `From: ${expandedFrom}\nSubject: ${expandedSubject}\n\n${expandedBody}`;

        const pastDate = new Date(now - (i + 1) * 86400000 * (1 + (now % 3)));
        const dateStr = pastDate.toLocaleString('en-US', {
            weekday: 'short', month: 'short', day: 'numeric',
            hour: '2-digit', minute: '2-digit', hour12: false,
        });
        const dated = `Date: ${dateStr}\n${fullEmail}`;
        const existing = vfsReadFile(VFS_MAILBOX) || '';
        const separator = existing ? '\n\n---\n\n' : '';
        vfsWriteFile(VFS_MAILBOX, existing + separator + dated);
    }

    vfsWriteFile(VFS_MAIL_SEEDED, '1');
}
 
export function init_mail_globals() {
    if (game) {
        game._mailState = null;
        game._pendingMail = null;
    }
}