All files / js steed.js

58.07% Statements 399/687
44.8% Branches 56/125
45.45% Functions 10/22
58.07% Lines 399/687

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 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 68873x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x 73x               73x 73x 73x 73x 73x 73x   73x 73x             73x 73x 73x 73x 73x 73x 73x                     73x 73x 73x 73x 73x 73x 73x 73x   73x 73x 73x 73x 73x 73x 14153x 14153x 14153x 14153x 73x 73x 73x 73x     73x 73x 73x 73x 31x 31x 31x 31x 31x 31x 31x 73x 73x 73x 73x 73x                                                                                                                                                 73x 73x 73x 73x 5x     5x 5x 5x 5x 5x 5x       5x 5x 5x 5x 5x 73x 73x 73x 73x 3x 3x 3x 3x 3x 73x 73x 73x 73x 9x 9x 9x 2x 8x 7x 5x 5x 5x 5x 5x 7x 2x 2x 2x 9x 73x 73x 73x 73x 5x 5x 5x       5x       5x       5x               5x       5x 5x 3x 5x 2x 2x 2x 3x 5x 5x             3x 3x 5x       3x 3x 5x       5x       5x       3x 5x           5x       5x       3x 3x 5x 5x       3x 3x 3x 3x 5x 1x       1x 1x 1x 1x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 5x 5x 2x 2x 2x 2x 2x 2x 2x 5x 73x 73x 73x 73x                 73x 73x 73x 73x                                                                                 73x 73x 73x 73x 2x 2x 2x 2x 2x 16x 16x 16x 16x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 16x 16x 16x 16x 16x 16x 14x 14x 14x 14x 5x 5x 5x 5x 5x 5x 5x 14x 16x 2x 2x 2x     2x 2x 2x 73x 73x 73x 73x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x   2x 2x 2x                 2x         2x   2x   2x   2x 2x 2x         2x       2x 2x 2x 2x 2x     2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x     2x 2x 2x 2x 1x 1x 2x 2x 2x 2x 2x               2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x                         2x 2x 2x 2x 2x 2x 2x 2x 2x     2x 2x 2x 2x 73x 73x 73x 73x 2x 2x 2x 2x 2x                 2x 2x 2x 2x 73x 73x 73x 73x                       73x 73x 73x 73x                           73x 73x 73x 73x 14180x 14180x 14180x 14180x 14180x 14180x 14180x 14180x 14180x 14180x 73x 73x 73x 73x  
// steed.js — Steed (riding) routines (port of steed.c)
// C ref: steed.c — 934 lines
// Handles mounting, dismounting, and steed-related actions.
 
import { game } from './gstate.js';
import { enexto } from './makemon.js';
import { accessible } from './monmove.js';
import { rn1, rn2, rnd } from './rng.js';
import { pline, You, You_cant } from './pline.js';
import { cansee } from './vision.js';
import { DEADMONSTER, Monnam, a_monnam, mon_nam, x_monnam } from './mon.js';
import { canspotmon, newsym, SET_BOTL, setBotl } from './display.js';
import { MON_AT, m_at } from './map_access.js';
import { which_armor } from './worn.js';
import { mons, S_QUADRUPED, S_UNICORN, S_ANGEL, S_CENTAUR,
    S_DRAGON, S_JABBERWOCK, MZ_MEDIUM, PM_KNIGHT } from './monsters.js';
import { amorphous, bigmonst, breathless, grounded, humanoid, is_amphibious, is_floater, is_flyer, is_pole, is_swimmer, is_whirly, likes_lava, nohands, noncorporeal, verysmall } from './mondata.js';
import { ARTICLE_A, ARTICLE_YOUR, BOTH_SIDES, DISMOUNT_BONES, DISMOUNT_BYCHOICE, DISMOUNT_ENGULFED, DISMOUNT_FELL, DISMOUNT_GENERIC, DISMOUNT_KNOCKED, DISMOUNT_POLY, DISMOUNT_THROWN, ECMD_CANCEL, ECMD_OK, ECMD_TIME, HAND, has_mgivenname, I_SPECIAL, isok, KILLED_BY_AN, LEVITATION, M_AP_FURNITURE, M_AP_OBJECT, M_AP_TYPE, MAXULEV, MON_FLOOR, N_DIRS, NO_KILLER_PREFIX, NON_PM, P_BASIC, P_EXPERT, P_ISRESTRICTED, P_RIDING, P_SKILLED, P_UNSKILLED, SLT_ENCUMBER, STEALTH, SUPPRESS_HALLUCINATION, SUPPRESS_INVISIBLE, SUPPRESS_IT, SUPPRESS_SADDLE, TIMEOUT, u_at, W_ARTI, W_SADDLE, WOUNDED_LEGS } from './const.js';
import { Upolyd } from './macros.js';
import { BOULDER, greatest_erosion, SADDLE } from './objects.js';
import { mpickobj, remove_worn_item } from './steal.js';
import { mksobj, sobj_at } from './mkobj.js';
import { freeinv, fully_identify_obj } from './invent.js';
import { update_mon_extrinsics } from './worn.js';
import { ACURR, adjalign } from './attrib.js';
import { P_SKILL, use_skill } from './weapon.js';
import { pmname } from './do_name.js';
import { mhe } from './mon.js';
import { distu, highc, Mgender, monverbself, unsolid } from './hacklib.js';
import { set_wounded_legs, heal_legs } from './do.js';
import { rloc, rloc_to, teleds } from './teleport.js';
 
import { finish_meating } from './dogmove.js';
import { exercise } from './attrib.js';
import { helpless, killed, losehp, monkilled, test_move } from './hack.js';
import { slithy, touch_petrifies } from './mondata.js';
import { freehand } from './engrave.js';
import { body_part } from './polyself.js';
import { m_unleash } from './apply.js';
import { getdir } from './cmd.js';
import { Blind, Blind_telepat, Confusion, Flying, Fumbling, Glib, Hallucination, Levitation, Maybe_Half_Phys, Punished, Role_if, Stone_resistance, Stunned, Underwater, Wounded_legs, u_locomotion } from './macros.js';
import { an } from './objnam.js';
import { encumber_msg, near_capacity } from './pickup.js';
import { steed_vs_stealth } from './polyself.js';
import { float_down, mintrap, sokoban_guilt } from './trap.js';
 
// Local stubs for functions not yet exported
 // TODO: import from macros
 // TODO
 // TODO
 // TODO
 // TODO
 // TODO
// C ref: youprop.h — Lev_at_will: levitation from special/artifact only, no other sources
function Lev_at_will() {
    const hlev = game.u?.uprops?.[LEVITATION]?.intrinsic || 0;
    const elev = game.u?.uprops?.[LEVITATION]?.extrinsic || 0;
    return (((hlev & I_SPECIAL) !== 0 || (elev & W_ARTI) !== 0)
         && (hlev & ~(I_SPECIAL | TIMEOUT)) === 0
         && (elev & ~W_ARTI) === 0);
}
 // TODO
 // TODO
 // TODO
 // TODO
 // TODO
// C ref: youprop.h — HWounded_legs = u.uprops[WOUNDED_LEGS].intrinsic
function HWounded_legs() { return game.u?.uprops?.[WOUNDED_LEGS]?.intrinsic ?? 0; }
 // TODO
// C ref: youprop.h — Stealth = ((HStealth || EStealth) && !BStealth)
function Stealth() {
    const h = game.u?.uprops?.[STEALTH]?.intrinsic || 0;
    const e = game.u?.uprops?.[STEALTH]?.extrinsic || 0;
    const b = game.u?.uprops?.[STEALTH]?.blocked || 0;
    return !!((h || e) && !b);
}
// MON_AT: imported from map_access.js
// SET_BOTL imported from display.js
 // TODO
// greatest_erosion: imported from objects.js
// is_pole: imported from mondata.js
 // TODO
// C ref: pickup.c:2930 — u_handsy: checks if hero can use hands
async function u_handsy() {
    if (nohands(game.youmonst?.data)) {
        await You("have no hands!");
        return false;
    } else if (!freehand()) {
        await You(`have no free ${body_part(HAND)}.`);
        return false;
    }
    return true;
}
 // TODO
// accessible: imported from monmove.js
// enexto: imported from makemon.js
// monverbself: imported from hacklib.js
// u_locomotion: imported from macros.js
 // TODO
// grounded: imported from mondata.js
// C ref: mondata.h — cant_drown: swimmer, amphibious, or breathless
function cant_drown(mdat) { return is_swimmer(mdat) || is_amphibious(mdat) || breathless(mdat); }
// likes_lava imported from mondata.js
function YMonnam(mtmp) { return `Your ${mon_nam(mtmp)}`; } // simplified
 
// C ref: steed.c:8 — the steeds array
const steeds = [S_QUADRUPED, S_UNICORN, S_ANGEL, S_CENTAUR, S_DRAGON, S_JABBERWOCK];
 
function remove_monster(x, y) {
    const level = game.level;
    if (level) level.setMonAt(x, y, null);
}
 
// ── rider_cant_reach ──
// C ref: steed.c:17
export async function rider_cant_reach() {
    await You("aren't skilled enough to reach from the saddle.");
}
 
// ── can_saddle ──
// C ref: steed.c:26
export function can_saddle(mtmp) {
    const ptr = mtmp?.data;
    if (!ptr) return false;
    return steeds.includes(ptr.mlet) && (ptr.msize >= MZ_MEDIUM)
        && (!humanoid(ptr) || ptr.mlet === S_CENTAUR)
        && !amorphous(ptr) && !noncorporeal(ptr)
        && !is_whirly(ptr) && !unsolid(ptr);
}
 
 
// ── use_saddle ──
// C ref: steed.c:35
export async function use_saddle(otmp) {
    if (!await u_handsy()) return ECMD_OK;

    if (game.u?.uswallow || Underwater() || !await getdir(null)) {
        await pline("Never mind.");
        return ECMD_CANCEL;
    }
    if (!game.u.dx && !game.u.dy) {
        await pline("Saddle yourself?  Very funny...");
        return ECMD_OK;
    }
    const tx = game.u.ux + (game.u.dx || 0);
    const ty = game.u.uy + (game.u.dy || 0);
    if (!isok(tx, ty)) {
        await pline("I see nobody there.");
        return ECMD_TIME;
    }
    const mtmp = m_at(tx, ty);
    if (!mtmp || !canspotmon(mtmp)) {
        await pline("I see nobody there.");
        return ECMD_TIME;
    }

    if ((mtmp.misc_worn_check & W_SADDLE) || which_armor(mtmp, W_SADDLE)) {
        await pline(`${Monnam(mtmp)} doesn't need another one.`);
        return ECMD_TIME;
    }
    const ptr = mtmp.data;
    if (touch_petrifies(ptr) && !Stone_resistance()) {
        await You(`touch ${mon_nam(mtmp)}.`);
        // instapetrify — TODO
    }
    if (mtmp.isminion || mtmp.isshk || mtmp.ispriest || mtmp.isgd
        || mtmp.iswiz) {
        await pline(`I think ${mon_nam(mtmp)} would mind.`);
        return ECMD_TIME;
    }
    if (!can_saddle(mtmp)) {
        await You_cant("saddle such a creature.");
        return ECMD_TIME;
    }

    // Calculate chance
    let chance = ACURR(3 /* A_DEX */) + Math.trunc(ACURR(5 /* A_CHA */) / 2)
        + 2 * (mtmp.mtame || 0);
    chance += (game.u.ulevel || 1) * (mtmp.mtame ? 20 : 5);
    if (!mtmp.mtame) chance -= 10 * (mtmp.m_lev || 0);
    if (Role_if(PM_KNIGHT)) chance += 20;
    switch (P_SKILL(P_RIDING)) {
    case P_ISRESTRICTED:
    case P_UNSKILLED:
    default:
        chance -= 20; break;
    case P_BASIC: break;
    case P_SKILLED: chance += 15; break;
    case P_EXPERT: chance += 30; break;
    }
    if (Confusion() || Fumbling() || Glib()) chance -= 20;

    if (otmp?.cursed) chance -= 50;

    await maybewakesteed(mtmp);

    if (rn2(100) < chance) {
        await You(`put the saddle on ${mon_nam(mtmp)}.`);
        if (otmp.owornmask) await remove_worn_item(otmp, false);
        freeinv(otmp);
        await put_saddle_on_mon(otmp, mtmp);
    } else {
        await pline(`${Monnam(mtmp)} resists!`);
    }
    return ECMD_TIME;
}
 
// ── put_saddle_on_mon ──
// C ref: steed.c:141
export async function put_saddle_on_mon(saddle, mtmp) {
    if (!can_saddle(mtmp) || which_armor(mtmp, W_SADDLE)) {
        return;
    }
    if (!saddle) {
        saddle = mksobj(SADDLE, true, false);
        if (!saddle) return;
        fully_identify_obj(saddle);
    }
    if (await mpickobj(mtmp, saddle)) {
        // merged — shouldn't happen for saddle
        return;
    }
    mtmp.misc_worn_check = (mtmp.misc_worn_check || 0) | W_SADDLE;
    saddle.owornmask = W_SADDLE;
    saddle.leashmon = mtmp.m_id || 0;
    await update_mon_extrinsics(mtmp, saddle, true, false);
}
 
// ── can_ride ──
// C ref: steed.c:168
export function can_ride(mtmp) {
    const youmonst = game.youmonst || { data: game.u?.umonst?.data || mons[game.u?.umonnum] };
    return !!(mtmp?.mtame && humanoid(youmonst.data)
        && !verysmall(youmonst.data) && !bigmonst(youmonst.data)
        && (!Underwater() || is_swimmer(mtmp?.data)));
}
 
// ── doride ──
// C ref: steed.c:178
export async function doride() {
    let forcemount = false;
 
    if (game.u?.usteed) {
        await dismount_steed(DISMOUNT_BYCHOICE);
    } else if (await getdir(null) && isok(game.u.ux + (game.u.dx || 0),
                                     game.u.uy + (game.u.dy || 0))) {
        // wizard mode force not ported
        const result = await mount_steed(
            m_at(game.u.ux + (game.u.dx || 0), game.u.uy + (game.u.dy || 0)),
            forcemount);
        return result ? ECMD_TIME : ECMD_OK;
    } else {
        return ECMD_CANCEL;
    }
    return ECMD_TIME;
}
 
// ── mount_steed ──
// C ref: steed.c:197
export async function mount_steed(mtmp, force) {
    const u = game.u;
 
    if (u.usteed) {
        await You(`are already riding ${mon_nam(u.usteed)}.`);
        return false;
    }
    if (Hallucination() && !force) {
        await pline("Maybe you should find a designated driver.");
        return false;
    }
    if (Wounded_legs()) {
        // legs_in_no_shape — TODO
        return false;
    }
    if (Upolyd()) {
        const youdata = game.youmonst?.data;
        if (youdata && (!humanoid(youdata) || verysmall(youdata)
            || bigmonst(youdata) || slithy(youdata))) {
            await You("won't fit on a saddle.");
            return false;
        }
    }
    if (!force && near_capacity() > SLT_ENCUMBER) {
        await You_cant("do that while carrying so much stuff.");
        return false;
    }
 
    if (!mtmp || (!force && ((Blind() && !Blind_telepat()) || mtmp.mundetected
        || M_AP_TYPE(mtmp) === M_AP_FURNITURE
        || M_AP_TYPE(mtmp) === M_AP_OBJECT))) {
        await pline("I see nobody there.");
        return false;
    }
 
    if (u.uswallow || u.ustuck || u.utrap || Punished()
        || !await test_move(u.ux, u.uy, mtmp.mx - u.ux, mtmp.my - u.uy, 0 /* TEST_MOVE */)) {
        if (Punished() || !(u.uswallow || u.ustuck || u.utrap))
            await You("are unable to swing your leg over.");
        else
            await You("are stuck here for now.");
        return false;
    }
 
    const otmp = which_armor(mtmp, W_SADDLE);
    if (!otmp) {
        await pline(`${Monnam(mtmp)} is not saddled.`);
        return false;
    }
 
    const ptr = mtmp.data;
    if (touch_petrifies(ptr) && !Stone_resistance()) {
        await You(`touch ${mon_nam(mtmp)}.`);
        // instapetrify — TODO
    }
    if (!mtmp.mtame || mtmp.isminion) {
        await pline(`I think ${mon_nam(mtmp)} would mind.`);
        return false;
    }
    if (mtmp.mtrapped) {
        await You_cant(`mount ${mon_nam(mtmp)} while it's trapped.`);
        return false;
    }
 
    if (!force && !Role_if(PM_KNIGHT) && !(--mtmp.mtame)) {
        newsym(mtmp.mx, mtmp.my);
        await pline(`${Monnam(mtmp)} resists${mtmp.mleashed ? " and its leash comes off" : ""}!`);
        if (mtmp.mleashed) await m_unleash(mtmp, false);
        return false;
    }
    if (!force && Underwater() && !is_swimmer(ptr)) {
        await You_cant("ride that creature while under water.");
        return false;
    }
    if (!can_saddle(mtmp) || !can_ride(mtmp)) {
        await You_cant("ride such a creature.");
        return false;
    }
 
    // Impairment check
    if (!force && !is_floater(ptr) && !is_flyer(ptr) && Levitation()
        && !Lev_at_will()) {
        await You(`cannot reach ${mon_nam(mtmp)}.`);
        return false;
    }
 
    if (!force
        && (Confusion() || Fumbling() || Glib() || Wounded_legs()
            || otmp.cursed || otmp.greased
            || ((u.ulevel || 1) + (mtmp.mtame || 0) < rnd(Math.trunc(MAXULEV / 2) + 5)))) {
        if (Levitation()) {
            await pline(`${Monnam(mtmp)} slips away from you.`);
            return false;
        }
        await You(`slip while trying to get on ${mon_nam(mtmp)}.`);
        await losehp(Maybe_Half_Phys(rn1(5, 10)), "riding accident", KILLED_BY_AN);
        return false;
    }
 
    // Success
    await maybewakesteed(mtmp);
    if (!force) {
        if (Levitation() && !is_floater(ptr) && !is_flyer(ptr))
            await pline(`${Monnam(mtmp)} magically floats up!`);
        await You(`mount ${mon_nam(mtmp)}.`);
        if (Flying())
            await You(`and ${mon_nam(mtmp)} take flight together.`);
    }
 
    if (game.u.uwep && is_pole(game.u.uwep))
        game.unweapon = false;
    u.usteed = mtmp;
 
    steed_vs_stealth();
    remove_monster(mtmp.mx, mtmp.my);
    await teleds(mtmp.mx, mtmp.my, 0x1 /* TELEDS_ALLOW_DRAG */);
    setBotl('mount_steed');
    return true;
}
 
// ── exercise_steed ──
// C ref: steed.c:387
export function exercise_steed() {
    if (!game.u?.usteed) return;
    if (game.u.urideturns === undefined) game.u.urideturns = 0;
    game.u.urideturns++;
    if (game.u.urideturns >= 100) {
        game.u.urideturns = 0;
        use_skill(P_RIDING, 1);
    }
}
 
// ── kick_steed ──
// C ref: steed.c:402
export async function kick_steed() {
    const u = game.u;
    if (!u?.usteed) return;

    if (helpless(u.usteed)) {
        let He = mhe(u.usteed);
        He = highc(He.charAt(0)) + He.slice(1);
        if ((u.usteed.mcanmove || u.usteed.mfrozen) && !rn2(2)) {
            if (u.usteed.mcanmove)
                u.usteed.msleeping = 0;
            else if (u.usteed.mfrozen > 2)
                u.usteed.mfrozen -= 2;
            else {
                u.usteed.mfrozen = 0;
                u.usteed.mcanmove = 1;
            }
            if (helpless(u.usteed))
                await pline(`${He} stirs.`);
            else
                await pline(`${monverbself(u.usteed, He, "rouse", null)}!`);
        } else {
            await pline(`${He} does not respond.`);
        }
        return;
    }

    // Make the steed less tame and check if it resists
    if (u.usteed.mtame) u.usteed.mtame--;
    if (!u.usteed.mtame && u.usteed.mleashed)
        await m_unleash(u.usteed, true);
    if (!u.usteed.mtame
        || ((u.ulevel || 1) + (u.usteed.mtame || 0)
            < rnd(Math.trunc(MAXULEV / 2) + 5))) {
        newsym(u.usteed.mx, u.usteed.my);
        await dismount_steed(DISMOUNT_THROWN);
        return;
    }

    await pline(`${Monnam(u.usteed)} gallops!`);
    u.ugallop = (u.ugallop || 0) + rn1(20, 30);
}
 
// ── landing_spot ──
// C ref: steed.c:459
export async function landing_spot(spot, reason, forceit) {
    const u = game.u;
    const n_spots = [];
 
    // Build list of adjacent spots (simplified — no DISMOUNT_KNOCKED preferred dirs)
    for (let j = 0; j < N_DIRS; j++) {
        const dx = [0, -1, -1, -1, 0, 1, 1, 1][j];
        const dy = [-1, -1, 0, 1, 1, 1, 0, -1][j];
        n_spots.push({ x: dx, y: dy });
    }
 
    let min_distance = -1;
    let found = false;
    let viable = 0;
    const impaird = Stunned() || Confusion() || Fumbling();
 
    const start_pass = (reason === DISMOUNT_BYCHOICE && !impaird) ? 0
        : ((reason === DISMOUNT_BYCHOICE && impaird)
            || reason === DISMOUNT_KNOCKED) ? 1 : 2;
 
    for (let i = start_pass; i <= 2 && !found; i++) {
        for (let j = 0; j < n_spots.length; j++) {
            const x = u.ux + n_spots[j].x;
            const y = u.uy + n_spots[j].y;
            if (!isok(x, y) || u_at(x, y)) continue;
 
            if (accessible(x, y) && !MON_AT(x, y)
                && await test_move(u.ux, u.uy, x - u.ux, y - u.uy, 0 /* TEST_MOVE */)) {
                ++viable;
                const distance = distu(x, y);
                if (min_distance < 0 || distance < min_distance
                    || (distance === min_distance && !rn2(viable))) {
                    // Pass 0: avoid known traps; Pass 0-1: avoid boulders
                    // Simplified: just pick the spot
                    spot.x = x;
                    spot.y = y;
                    min_distance = distance;
                    found = true;
                }
            }
        }
    }
 
    if (forceit && !found) {
        found = enexto(spot, u.ux, u.uy, null);
    }
 
    return found;
}
 
// ── dismount_steed ──
// C ref: steed.c:576
export async function dismount_steed(reason) {
    const u = game.u;
    const mtmp = u?.usteed;
    if (!mtmp) return;
 
    const save_utrap = u.utrap || 0;
    const repair_leg_damage = Wounded_legs();
    const cc = { x: 0, y: 0 };
    let have_spot = await landing_spot(cc, reason, 0);
 
    // Sanity
    const otmp = which_armor(mtmp, W_SADDLE);
    let verb = 'fall';
 
    switch (reason) {
    case DISMOUNT_THROWN:
        verb = 'are thrown';
        // fallthrough
    case DISMOUNT_KNOCKED:
    case DISMOUNT_FELL:
        await You(`${verb} off of ${mon_nam(mtmp)}!`);
        if (!have_spot)
            have_spot = await landing_spot(cc, reason, 1);
        if (!Levitation() && !Flying()) {
            await losehp(Maybe_Half_Phys(rn1(10, 10)), "riding accident", KILLED_BY_AN);
            await set_wounded_legs(BOTH_SIDES, HWounded_legs() + rn1(5, 5));
        }
        break;
    case DISMOUNT_POLY:
        await You(`can no longer ride ${mon_nam(u.usteed)}.`);
        if (!have_spot)
            have_spot = await landing_spot(cc, reason, 1);
        break;
    case DISMOUNT_ENGULFED:
        break;
    case DISMOUNT_BONES:
        break;
    case DISMOUNT_GENERIC:
        break;
    case DISMOUNT_BYCHOICE:
    default:
        if (otmp && otmp.cursed) {
            await You(`can't.  The saddle ${otmp.bknown ? 'is' : 'seems to be'} cursed.`);
            otmp.bknown = 1;
            return;
        }
        if (!have_spot) {
            await You("can't.  There isn't anywhere for you to stand.");
            return;
        }
        if (!has_mgivenname(mtmp)) {
            await pline(`You've been through the dungeon on ${an(pmname(mtmp.data, Mgender(mtmp)))} with no name.`);
            if (Hallucination())
                await pline("It felt good to get out of the rain.");
        } else {
            await You(`dismount ${mon_nam(mtmp)}.`);
        }
    }
 
    if (repair_leg_damage) await heal_legs(1);
 
    // Release the steed
    u.usteed = null;
    u.ugallop = 0;
    steed_vs_stealth();
 
    if (u.utraptype === 2 /* TT_BEARTRAP */
        || u.utraptype === 3 /* TT_PIT */
        || u.utraptype === 5 /* TT_WEB */) {
        mtmp.mtrapped = 1;
    }
 
    const steedcc = { x: u.ux, y: u.uy };
    // C ref: steed.c:689 — check for engulfer at hero's spot
    if (m_at(u.ux, u.uy)) {
        enexto(steedcc, u.ux, u.uy, mtmp.data);
    }
 
    if (!DEADMONSTER(mtmp)) {
        place_monster(mtmp, steedcc.x, steedcc.y);
 
        if (reason === DISMOUNT_BONES) {
            const bcc = { x: 0, y: 0 };
            if (enexto(bcc, u.ux, u.uy, mtmp.data))
                await rloc_to(mtmp, bcc.x, bcc.y);
            else
                await rloc(mtmp, 0);
            return;
        }
 
        if (!u.uswallow && !u.ustuck && have_spot) {
            if (!DEADMONSTER(mtmp)) {
                await teleds(cc.x, cc.y, 0x1 /* TELEDS_ALLOW_DRAG */);
                if (sobj_at(BOULDER, cc.x, cc.y))
                    sokoban_guilt();
 
                if (save_utrap)
                    await mintrap(mtmp, 0);
            }
        } else {
            const ecc = { x: 0, y: 0 };
            if (enexto(ecc, u.ux, u.uy, mtmp.data)) {
                await rloc_to(mtmp, ecc.x, ecc.y);
            } else {
                if (reason === DISMOUNT_BYCHOICE) {
                    await killed(mtmp);
                    adjalign(-1);
                } else {
                    await monkilled(mtmp, "", 0);
                }
            }
        }
    }
 
    if (reason !== DISMOUNT_ENGULFED && reason !== DISMOUNT_BONES) {
        await float_down(0, W_SADDLE);
        setBotl('dismount_steed');
        await encumber_msg();
        if (game.vision_full_recalc !== undefined)
            game.vision_full_recalc = 1;
    } else {
        setBotl('dismount_steed');
    }
 
    if (game.u.uwep && is_pole(game.u.uwep))
        game.unweapon = true;
}
 
// ── maybewakesteed ──
// C ref: steed.c:827
export async function maybewakesteed(steed) {
    let frozen = steed.mfrozen || 0;
    const wasimmobile = helpless(steed);
 
    steed.msleeping = 0;
    if (frozen) {
        frozen = Math.trunc((frozen + 1) / 2);
        if (!rn2(frozen)) {
            steed.mfrozen = 0;
            steed.mcanmove = 1;
        } else {
            steed.mfrozen = frozen;
        }
    }
    if (wasimmobile && !helpless(steed))
        await pline(`${Monnam(steed)} wakes up.`);
    finish_meating(steed);
}
 
// ── poly_steed ──
// C ref: steed.c:852
export async function poly_steed(steed, oldshape) {
    if (!can_saddle(steed) || !can_ride(steed)) {
        await dismount_steed(DISMOUNT_FELL);
    } else {
        let buf = x_monnam(steed, ARTICLE_YOUR, null,
            typeof SUPPRESS_SADDLE !== 'undefined' ? SUPPRESS_SADDLE : 0, false);
        if (oldshape !== steed.data)
            buf = buf.replace('your ', 'your new ');
        await You(`adjust yourself in the saddle on ${buf}.`);
        steed_vs_stealth();
    }
}
 
// ── stucksteed ──
// C ref: steed.c:878
export async function stucksteed(checkfeeding) {
    const steed = game.u?.usteed;
    if (steed) {
        if (helpless(steed)) {
            await pline(`${YMonnam(steed)} won't move!`);
            return true;
        }
        if (checkfeeding && steed.meating) {
            await pline(`${YMonnam(steed)} is still eating.`);
            return true;
        }
    }
    return false;
}
 
// ── place_monster ──
// C ref: steed.c:898
export function place_monster(mon, x, y) {
    if (!mon) return;
    // C checks: isok, usteed, DEADMONSTER — simplified
    if (mon === game.u?.usteed && !game.in_steed_dismounting) return;
    if (DEADMONSTER(mon)) return;
    mon.mx = x;
    mon.my = y;
    const level = game.level;
    if (level) level.setMonAt(x, y, mon);
    mon.mstate = MON_FLOOR;
}
 
// ── remove_monster ──
// C ref: rm.h macro — clears monster pointer at (x,y) in the dungeon grid
export { remove_monster, remove_monster as remove_monster_steed };