<?php

declare(strict_types=1);

/**
 * STAR MERCHANT — flagship door (SPEC §15). Turn-based space trading in a
 * persistent shared galaxy: warp, arbitrage the ports, found planets,
 * raid the careless. Original work, top to bottom.
 *
 * Flow: one sector-view screen with a hotkey prompt; sub-modes (move,
 * trade, dock, attack, beacon, computer) ride line prompts through the
 * state bag's 'mode'. Every warp and scan costs a turn; the day resets
 * them.
 */

namespace Doors\StarMerchant;

require_once __DIR__ . '/universe.php';

use Atdt\DoorContext;
use Atdt\DoorInterface;
use Atdt\In;

final class Door implements DoorInterface
{
    private const ROW_MSG = 24;
    private const HOLD_PRICE = 50;
    private const FIGHTER_PRICE = 25;
    private const SHIELD_PRICE = 100;
    private const SEEDSHIP_PRICE = 5000;
    private const BEACON_PRICE = 250;
    private const MAX_HOLDS = 200;
    private const MAX_SHIELDS = 100;

    public function enter(DoorContext $ctx): array
    {
        $u = new Universe($ctx->kv());
        $u->ensureTables();
        if (!$u->generated()) {
            $t0 = microtime(true);
            $u->generate();
            $ctx->log(sprintf('universe generated in %.2fs', microtime(true) - $t0));
        }
        $p = $this->player($ctx, $u);
        $ctx->state['mode'] = 'cmd';

        // Overnight news first: raids, production — mail-style, then gone.
        $notes = $u->notices()->rows(['player' => $ctx->userId()], 'id ASC', 10);
        if ($notes !== []) {
            $u->notices()->delete(['player' => $ctx->userId()]);
            $o = $ctx->out()->cls();
            $o->at(2, 1)->txt($ctx->center($ctx->pipe('|14SUBSPACE TRAFFIC FOR ' . strtoupper($ctx->handle()))));
            $o->at(3, 1)->fg(8)->txt($ctx->center(str_repeat('─', 50)))->reset();
            $row = 5;
            foreach ($notes as $n) {
                $o->at($row++, 4)->txt($ctx->pipe('|08' . $ctx->localStamp((string)$n['at'], 'm-d') . ' |07'
                    . \Atdt\Pipe::escape(mb_strimwidth((string)$n['body'], 0, 70))));
            }
            $o->at(22, 1)->txt($ctx->center($ctx->pipe('|07Any key for the sector view.')));
            $ctx->state['mode'] = 'news';
            return [$ctx->ansi($o->reset()->str()), $ctx->hotkey()];
        }
        if (!empty($ctx->state['fresh'])) {
            unset($ctx->state['fresh']);
            return $this->help($ctx, 1);
        }
        return $this->sector($ctx, $u);
    }

    public function input(DoorContext $ctx, In $in): array
    {
        $u = new Universe($ctx->kv());
        $mode = (string)($ctx->state['mode'] ?? 'cmd');

        if ($in->kind === 'line') {
            return $this->lineEntered($ctx, $u, $in->line);
        }
        if ($in->kind !== 'key') {
            return [$ctx->bell()];
        }
        if ($mode === 'help2') {
            return $this->help($ctx, 2);
        }
        if ($mode === 'news' || $mode === 'pause') {
            $ctx->state['mode'] = 'cmd';
            return $this->sector($ctx, $u);
        }
        if ($mode === 'pause_dock') {
            return $this->dock($ctx, $u, $this->player($ctx, $u));
        }
        if ($mode === 'dock') {
            return $this->dockKey($ctx, $u, strtoupper((string)$in->key));
        }
        if ($mode !== 'cmd') {
            return [$ctx->bell()];
        }
        $p = $this->player($ctx, $u);
        $key = strtoupper((string)$in->key);
        if ($key >= '1' && $key <= '9') {
            $outs = $u->warpsOut((int)$p['sector']);
            $pick = $outs[(int)$key - 1] ?? null;
            return $pick === null ? [$ctx->bell()] : $this->warpTo($ctx, $u, $p, $pick);
        }
        return match ($key) {
            'M' => $this->prompt($ctx, 'move', '|03Autopilot to sector |08#|03: |15', 5),
            'P' => $this->portScreen($ctx, $u, $p),
            'L' => $this->land($ctx, $u, $p),
            'D' => $this->dock($ctx, $u, $p),
            'S' => $this->scan($ctx, $u, $p),
            'C' => $this->computer($ctx, $u, $p),
            'A' => $this->attackMenu($ctx, $u, $p),
            'B' => $this->prompt($ctx, 'beacon', '|03New beacon |08(' . self::BEACON_PRICE . ' cr, 60 chars)|03: |15', 60),
            'R' => $this->rankings($ctx, $u),
            '?', 'H' => $this->help($ctx, 1),
            'Q', 'ESC' => [$ctx->exitDoor()],
            default => [$ctx->bell()],
        };
    }

    /** The merchant's primer (owner request): two pages, any key walks on. */
    private function help(DoorContext $ctx, int $page): array
    {
        $o = $ctx->out()->cls();
        $o->at(2, 1)->txt($ctx->center($ctx->pipe('|14THE MERCHANT\'S PRIMER |08· |07page ' . $page . '/2')));
        $o->at(3, 1)->fg(8)->txt($ctx->center(str_repeat('─', 56)))->reset();
        $row = 5;
        $lines = $page === 1 ? [
            '|03The game|07: trade a shared galaxy that keeps living while you sleep.',
            'Every warp and scan costs a |15turn|07; you get a fresh budget at board',
            'midnight. Grow your net worth and climb the weekly rankings ladder.',
            '',
            '|03Trading|07: ports |10Sell|07 or |12Buy|07 each commodity (Ore, Biologics, Tech).',
            'Buy from |15full|07 ports (glut = cheap, down to 0.4x base) and sell to',
            '|15empty|07 ones (famine = dear, up to 2.4x). Check stock numbers at [P],',
            'chart what you have seen with the [C]omputer, and watch for beacons.',
            '',
            '|03Prime Station|07 (sector 1, [D]ock): buy holds/fighters/shields, bank',
            'credits (raid-proof, 0.5%/day interest), hear rumors in the cantina.',
            '|03Charter Space|07 (sectors 1-9) is patrolled: no combat, no pirates.',
        ] : [
            '|03Fighting|07: [A]ttack works on |12pirate cutters|07 (bounty on their head,',
            'they board you for 10% of your wallet if you lose), rival |13ships|07',
            '(win: scoop cargo + half their wallet; they pod home, bank intact),',
            'and |10planet garrisons|07 (win: the planet and everything on it).',
            'Losing a ship fight yourself sends YOU to Prime the same way.',
            '',
            '|03Planets|07: buy a Seedship at Prime (5,000), [L]and in an empty wild',
            'sector to found a world, ferry colonists from |10Gaia|07 (sector 2) and',
            'they produce goods daily by planet class. Garrison fighters to hold',
            'it. [L]and on your world to collect the warehouse.',
            '',
            '|03Odds and ends|07: [S]can burns a turn but shows pirates, derelict',
            'salvage, and ports next door. [B]eacons cost 250 and say 60 chars.',
            'Empty line or [Q] leaves any counter. Good hunting, merchant.',
        ];
        foreach ($lines as $line) {
            $o->at($row++, 8)->txt($ctx->pipe($line));
        }
        $o->at(22, 1)->txt($ctx->center($ctx->pipe($page === 1
            ? '|07Any key for page 2.'
            : '|07Any key returns to the sector view.')));
        $ctx->state['mode'] = $page === 1 ? 'help2' : 'pause';
        return [$ctx->ansi($o->reset()->str()), $ctx->hotkey()];
    }

    public function leave(DoorContext $ctx): void
    {
        $ctx->log($ctx->handle() . ' undocked from the game');
    }

    // ---- the caller's ship ----------------------------------------------

    private function player(DoorContext $ctx, Universe $u): array
    {
        $p = $u->players()->one(['user_id' => $ctx->userId()]);
        if ($p === null) {
            $u->players()->insert([
                'user_id' => $ctx->userId(), 'handle' => $ctx->handle(),
                'credits' => 2000, 'bank' => 0, 'turns' => $u->cfg('turns', 250),
                'sector' => 1, 'holds' => 20, 'fighters' => 0, 'shields' => 0,
                'ore' => 0, 'bio' => 0, 'tech' => 0, 'colonists' => 0,
                'kills' => 0, 'deaths' => 0, 'has_seedship' => 0,
            ]);
            $p = $u->players()->one(['user_id' => $ctx->userId()]);
            $u->logEvent($ctx->handle() . ' bought a Wren and a dream.');
            $ctx->state['fresh'] = true;   // enter() greets first-timers with the primer
        }
        return $p;
    }

    private function save(Universe $u, array $p): void
    {
        $u->players()->update(
            array_diff_key($p, ['id' => 1, 'user_id' => 1]),
            ['user_id' => (int)$p['user_id']]
        );
    }

    private function freeHolds(array $p): int
    {
        return max(0, (int)$p['holds'] - (int)$p['ore'] - (int)$p['bio'] - (int)$p['tech']
            - intdiv((int)$p['colonists'] + Universe::COLONISTS_PER_HOLD - 1, Universe::COLONISTS_PER_HOLD));
    }

    // ---- the sector view (§15.3) ----------------------------------------

    private function sector(DoorContext $ctx, Universe $u, string $msg = ''): array
    {
        $p = $this->player($ctx, $u);
        $sid = (int)$p['sector'];
        $ctx->state['mode'] = 'cmd';
        $this->markVisit($ctx, $u, $p);

        // Drifting salvage collects itself on arrival (§15.4).
        foreach ($u->drifters()->rows(['sector' => $sid]) as $d) {
            $u->drifters()->delete(['id' => (int)$d['id']]);
            $p['credits'] = (int)$p['credits'] + (int)$d['loot'];
            $this->save($u, $p);
            $msg = '|14Derelict hulk! |07Salvage crews strip |15' . (int)$d['loot'] . '|07 credits from the wreck.';
            $u->logEvent($ctx->handle() . ' salvaged a drifter in sector ' . $sid . '.');
        }

        $o = $ctx->out()->cls();
        $o->at(1, 1)->bg(4)->fg(15)->txt(mb_strimwidth(sprintf(
            ' STAR MERCHANT · %s · %s cr (%s banked) · %d turns left',
            $ctx->handle(),
            number_format((int)$p['credits']),
            number_format((int)$p['bank']),
            (int)$p['turns']
        ) . str_repeat(' ', 80), 0, 80))->reset();

        $o->at(3, 3)->txt($ctx->pipe(sprintf(
            '|03Sector |15%d%s |08· |07holds |15%d|08/|15%d |08· |07fighters |15%d |08· |07shields |15%d',
            $sid,
            $sid <= Universe::CHARTER_MAX ? ' |08(Charter Space - patrolled)' : '',
            (int)$p['holds'] - $this->freeHolds($p),
            (int)$p['holds'],
            (int)$p['fighters'],
            (int)$p['shields']
        )));
        $row = 5;
        $sec = $u->sectors()->one(['sid' => $sid]);
        if ($sec !== null && trim((string)$sec['beacon']) !== '') {
            $o->at($row++, 3)->txt($ctx->pipe('|08beacon: |11' . \Atdt\Pipe::escape((string)$sec['beacon'])
                . ' |08- ' . \Atdt\Pipe::escape((string)$sec['beacon_owner'])));
        }
        if ($sid === 1) {
            $o->at($row++, 3)->txt($ctx->pipe('|14Prime Station|07: shipyard, bank, cantina, registrar. |08[D]ock to visit.'));
        }
        $port = $u->ports()->one(['sector' => $sid]);
        if ($port !== null) {
            $o->at($row++, 3)->txt($ctx->pipe(sprintf(
                '|07Trade port |15%s |08(class %d, %s) |07- [P] to trade',
                (string)$port['name'],
                (int)$port['pclass'],
                Universe::classLabel((int)$port['pclass'])
            )));
        }
        foreach ($u->planets()->rows(['sector' => $sid]) as $pl) {
            $who = (int)$pl['owner'] === 0 ? 'unclaimed'
                : ((int)$pl['owner'] === $ctx->userId() ? 'yours' : (string)$pl['owner_handle']);
            $o->at($row++, 3)->txt($ctx->pipe(sprintf(
                '|07Planet |10%s |08(%s, %s%s) |07- [L] to land',
                \Atdt\Pipe::escape((string)$pl['pname']),
                (string)$pl['pclass'],
                $who,
                (int)$pl['fighters'] > 0 ? ', |12' . (int)$pl['fighters'] . ' fighters|08' : ''
            )));
        }
        foreach ($u->players()->rows(['sector' => $sid], 'handle ASC') as $other) {
            if ((int)$other['user_id'] === $ctx->userId()) {
                continue;
            }
            $o->at($row++, 3)->txt($ctx->pipe('|07Docked ship: |13' . \Atdt\Pipe::escape((string)$other['handle'])
                . '|08 (' . (int)$other['fighters'] . 'f/' . (int)$other['shields'] . 's)'));
        }
        foreach ($u->marauders()->rows(['sector' => $sid]) as $mar) {
            $o->at($row++, 3)->txt($ctx->pipe(sprintf(
                '|12Pirate cutter "%s" |08(%df/%ds) |07- bounty |15%s|07 cr - [A]ttack',
                (string)$mar['name'],
                (int)$mar['fighters'],
                (int)$mar['shields'],
                number_format((int)$mar['bounty'])
            )));
        }

        $outs = $u->warpsOut($sid);
        $o->at(++$row, 3)->txt($ctx->pipe('|03Warps out|08:'));
        $col = 3;
        $row++;
        foreach (array_slice($outs, 0, 9) as $i => $to) {
            $known = $this->knownSectors($ctx, $u);
            $tag = isset($known[$to]) ? '' : '|08?';
            $o->at($row, $col)->txt($ctx->pipe(sprintf('|08[|14%d|08] |15%d%s', $i + 1, $to, $tag)));
            $col += 9;
            if ($col > 70) {
                $col = 3;
                $row++;
            }
        }

        if ($msg !== '') {
            $o->at(21, 1)->txt("\x1b[K")->txt($ctx->center($ctx->pipe($msg)));
        }
        $o->at(22, 2)->fg(8)->txt(str_repeat('─', 78))->reset();
        $o->at(23, 1)->txt($ctx->center($ctx->pipe(
            '|081-9|07 warp |08M|07ove |08P|07ort |08L|07and |08D|07ock |08S|07can |08C|07omp |08A|07ttack |08B|07eacon |08R|07anks |08?|07 help |08Q|07uit'
        )));
        return [$ctx->ansi($o->reset()->str()), $ctx->hotkey()];
    }

    private function markVisit(DoorContext $ctx, Universe $u, array $p): void
    {
        $sid = (int)$p['sector'];
        $port = $u->ports()->one(['sector' => $sid]);
        $seen = json_encode($port === null ? ['port' => null] : [
            'port' => ['class' => (int)$port['pclass'], 'name' => (string)$port['name'],
                'ore' => (int)$port['ore'], 'bio' => (int)$port['bio'], 'tech' => (int)$port['tech']],
        ]);
        $n = $u->visits()->update(
            ['seen' => $seen, 'at' => now_utc()],
            ['player' => $ctx->userId(), 'sector' => $sid]
        );
        if ($n === 0) {
            $u->visits()->insert(['player' => $ctx->userId(), 'sector' => $sid, 'seen' => $seen, 'at' => now_utc()]);
        }
    }

    /** @return array<int, true> sectors this player has visited */
    private function knownSectors(DoorContext $ctx, Universe $u): array
    {
        $known = [];
        foreach ($u->visits()->rows(['player' => $ctx->userId()]) as $v) {
            $known[(int)$v['sector']] = true;
        }
        return $known;
    }

    // ---- movement --------------------------------------------------------

    private function warpTo(DoorContext $ctx, Universe $u, array $p, int $to): array
    {
        if ((int)$p['turns'] <= 0) {
            return $this->sector($ctx, $u, '|12Out of turns - the lanes reopen at board midnight.');
        }
        $p['turns'] = (int)$p['turns'] - 1;
        $p['sector'] = $to;
        $this->save($u, $p);
        return $this->sector($ctx, $u);
    }

    private function autopilot(DoorContext $ctx, Universe $u, array $p, int $dest): array
    {
        $n = (int)$u->store->get('gen_sectors', '1000');
        if ($dest < 1 || $dest > $n) {
            return $this->sector($ctx, $u, '|12No such sector on the charts.');
        }
        $path = $u->route((int)$p['sector'], $dest);
        if ($path === null) {
            return $this->sector($ctx, $u, '|12The computer finds no route there from here.');
        }
        $hops = 0;
        foreach ($path as $next) {
            if ((int)$p['turns'] <= 0) {
                $this->save($u, $p);
                return $this->sector($ctx, $u, '|12Autopilot halted - out of turns. Reset at board midnight.');
            }
            // Hostile fighters ahead abort the run (§15.3).
            foreach ($u->planets()->rows(['sector' => $next]) as $pl) {
                if ((int)$pl['fighters'] > 0 && (int)$pl['owner'] !== $ctx->userId() && (int)$pl['owner'] !== 0) {
                    $this->save($u, $p);
                    return $this->sector($ctx, $u, '|12Autopilot aborts - hostile fighters ahead in sector ' . $next . '.');
                }
            }
            if ($u->marauders()->count(['sector' => $next]) > 0) {
                $this->save($u, $p);
                return $this->sector($ctx, $u, '|12Autopilot aborts - a pirate cutter prowls sector ' . $next . '.');
            }
            $p['sector'] = $next;
            $p['turns'] = (int)$p['turns'] - 1;
            $hops++;
        }
        $this->save($u, $p);
        return $this->sector($ctx, $u, sprintf('|07Autopilot: |15%d|07 hop%s, |15%d|07 turns spent.', $hops, $hops === 1 ? '' : 's', $hops));
    }

    // ---- trade (§15.2 pricing) ------------------------------------------

    private function portScreen(DoorContext $ctx, Universe $u, array $p, string $msg = ''): array
    {
        $port = $u->ports()->one(['sector' => (int)$p['sector']]);
        if ($port === null) {
            return $this->sector($ctx, $u, '|11No trade port in this sector.');
        }
        $pat = Universe::classPattern((int)$port['pclass']);
        $o = $ctx->out()->cls();
        $o->at(2, 1)->txt($ctx->center($ctx->pipe('|15' . (string)$port['name']
            . ' |08(class ' . (int)$port['pclass'] . ', ' . Universe::classLabel((int)$port['pclass']) . ')')));
        $o->at(3, 1)->fg(8)->txt($ctx->center(str_repeat('─', 56)))->reset();
        $o->at(5, 8)->txt($ctx->pipe('|03Commodity      Port      Stock     Price     You hold'));
        $row = 7;
        foreach (Universe::GOODS as $g) {
            $stock = (int)$port[$g];
            $sells = $pat[$g];
            $price = $sells ? Universe::pricePlayerBuys($g, $stock) : Universe::pricePlayerSells($g, $stock);
            $o->at($row++, 8)->txt($ctx->pipe(sprintf(
                '|15%-12s |%s |08%6d |15%9s |07%8d',
                Universe::GOOD_NAMES[$g],
                $sells ? '10Sells ' : '12Buys  ',
                $stock,
                number_format($price) . ' cr',
                (int)$p[$g]
            )));
        }
        $o->at(12, 8)->txt($ctx->pipe(sprintf(
            '|07Credits |15%s |08· |07free holds |15%d',
            number_format((int)$p['credits']),
            $this->freeHolds($p)
        )));
        if ($msg !== '') {
            $o->at(14, 1)->txt($ctx->center($ctx->pipe($msg)));
        }
        $o->at(17, 1)->txt($ctx->center($ctx->pipe('|07Deals like |15B ore 20|07 or |15S tech 5|07. Empty line leaves the counter.')));
        $ctx->state['mode'] = 'trade';
        $prompt = $ctx->pipe('|03Deal |08: |15');
        $o->at(19, 26)->txt($prompt);
        return [$ctx->ansi($o->reset()->str()), $ctx->line(33, 19, 12)];
    }

    private function trade(DoorContext $ctx, Universe $u, string $line): array
    {
        $p = $this->player($ctx, $u);
        $port = $u->ports()->one(['sector' => (int)$p['sector']]);
        if ($port === null) {
            return $this->sector($ctx, $u);
        }
        if (!preg_match('/^\s*([BS])\s+(ore|bio|tech)\s+(\d{1,6})\s*$/i', $line, $m)) {
            return $this->portScreen($ctx, $u, $p, '|12The clerk squints. Try |15B ore 20|12 or |15S tech 5|12.');
        }
        $buying = strtoupper($m[1]) === 'B';
        $good = strtolower($m[2]);
        $qty = (int)$m[3];
        $pat = Universe::classPattern((int)$port['pclass']);
        $stock = (int)$port[$good];
        if ($qty < 1) {
            return $this->portScreen($ctx, $u, $p, '|12Zero is not a deal.');
        }
        if ($buying) {
            if (!$pat[$good]) {
                return $this->portScreen($ctx, $u, $p, '|12They are buying ' . Universe::GOOD_NAMES[$good] . ', not selling it.');
            }
            $qty = min($qty, $stock, $this->freeHolds($p));
            $price = Universe::pricePlayerBuys($good, $stock);
            $cost = $price * $qty;
            if ($qty < 1) {
                return $this->portScreen($ctx, $u, $p, '|12No stock (or no free holds) for that.');
            }
            if ($cost > (int)$p['credits']) {
                $qty = intdiv((int)$p['credits'], $price);
                $cost = $price * $qty;
                if ($qty < 1) {
                    return $this->portScreen($ctx, $u, $p, '|12Your credit chip comes up short.');
                }
            }
            $p['credits'] = (int)$p['credits'] - $cost;
            $p[$good] = (int)$p[$good] + $qty;
            $u->ports()->update([$good => $stock - $qty], ['id' => (int)$port['id']]);
            $this->save($u, $p);
            return $this->portScreen($ctx, $u, $p, sprintf(
                '|10Bought |15%d %s|10 for |15%s|10 cr.',
                $qty,
                Universe::GOOD_NAMES[$good],
                number_format($cost)
            ));
        }
        if ($pat[$good]) {
            return $this->portScreen($ctx, $u, $p, '|12They sell ' . Universe::GOOD_NAMES[$good] . ' here - nobody buys it back.');
        }
        $qty = min($qty, (int)$p[$good]);
        if ($qty < 1) {
            return $this->portScreen($ctx, $u, $p, '|12Nothing of that in your holds.');
        }
        $price = Universe::pricePlayerSells($good, $stock);
        $gain = $price * $qty;
        $p['credits'] = (int)$p['credits'] + $gain;
        $p[$good] = (int)$p[$good] - $qty;
        $u->ports()->update([$good => min(Universe::MAX_STOCK, $stock + $qty)], ['id' => (int)$port['id']]);
        $this->save($u, $p);
        return $this->portScreen($ctx, $u, $p, sprintf(
            '|10Sold |15%d %s|10 for |15%s|10 cr.',
            $qty,
            Universe::GOOD_NAMES[$good],
            number_format($gain)
        ));
    }

    // ---- landing, planets, Gaia (§15.3) ---------------------------------

    private function land(DoorContext $ctx, Universe $u, array $p): array
    {
        $sid = (int)$p['sector'];
        $pl = $u->planets()->one(['sector' => $sid]);
        if ($pl === null) {
            if ((int)$p['has_seedship'] === 1 && $sid > Universe::CHARTER_MAX) {
                $ctx->state['mode'] = 'name_planet';
                return $this->prompt($ctx, 'name_planet', '|03Name the new world |08: |15', 20);
            }
            return $this->sector($ctx, $u, (int)$p['has_seedship'] === 1
                ? '|11Charter Space is spoken for. Seed beyond sector 9.'
                : '|11Nothing to land on here.');
        }
        if ((string)$pl['pclass'] === 'Gaia') {
            $room = $this->freeHolds($p) * Universe::COLONISTS_PER_HOLD;
            if ($room < 1) {
                return $this->sector($ctx, $u, '|11Gaia teems, but your holds are full.');
            }
            $p['colonists'] = (int)$p['colonists'] + $room;
            $this->save($u, $p);
            return $this->sector($ctx, $u, sprintf(
                '|10%s|07 colonists file aboard, dreaming of a new world.',
                number_format($room)
            ));
        }
        if ((int)$pl['owner'] !== $ctx->userId()) {
            return $this->sector($ctx, $u, '|12' . \Atdt\Pipe::escape((string)$pl['owner_handle'])
                . "'s ground batteries wave you off. |07[A]ttack if you mean it.");
        }
        // Your world: collect the warehouse, drop colonists + fighters.
        $msgs = [];
        foreach (Universe::GOODS as $g) {
            $take = min((int)$pl[$g], $this->freeHolds($p));
            if ($take > 0) {
                $p[$g] = (int)$p[$g] + $take;
                $pl[$g] = (int)$pl[$g] - $take;
                $msgs[] = $take . ' ' . Universe::GOOD_NAMES[$g];
            }
        }
        if ((int)$p['colonists'] > 0) {
            $pl['colonists'] = (int)$pl['colonists'] + (int)$p['colonists'];
            $msgs[] = number_format((int)$p['colonists']) . ' colonists settled';
            $p['colonists'] = 0;
        }
        if ((int)$p['fighters'] > 1) {
            $drop = intdiv((int)$p['fighters'], 2);
            $pl['fighters'] = (int)$pl['fighters'] + $drop;
            $p['fighters'] = (int)$p['fighters'] - $drop;
            $msgs[] = $drop . ' fighters garrisoned';
        }
        $u->planets()->update(
            array_diff_key($pl, ['id' => 1]),
            ['id' => (int)$pl['id']]
        );
        $this->save($u, $p);
        return $this->sector($ctx, $u, $msgs === []
            ? '|07The warehouse is empty; the colony waves anyway.'
            : '|10Landed: |07' . implode(', ', $msgs) . '.');
    }

    private function foundPlanet(DoorContext $ctx, Universe $u, string $name): array
    {
        $p = $this->player($ctx, $u);
        $name = trim(\Atdt\Chat::clean($name));
        if ($name === '' || mb_strlen($name) < 2) {
            return $this->sector($ctx, $u, '|11The seedship idles, unnamed.');
        }
        if ((int)$p['has_seedship'] !== 1 || $u->planets()->one(['sector' => (int)$p['sector']]) !== null) {
            return $this->sector($ctx, $u, '|12No seedship ready (or the sector is taken).');
        }
        $class = Universe::planetClassRoll();
        $u->planets()->insert([
            'sector' => (int)$p['sector'], 'owner' => $ctx->userId(), 'owner_handle' => $ctx->handle(),
            'pname' => mb_strimwidth($name, 0, 20), 'pclass' => $class,
            'colonists' => 0, 'ore' => 0, 'bio' => 0, 'tech' => 0, 'fighters' => 0,
        ]);
        $p['has_seedship'] = 0;
        $this->save($u, $p);
        $u->logEvent($ctx->handle() . ' seeded ' . $name . ' (' . $class . ') in sector ' . (int)$p['sector'] . '.');
        $ctx->award(25);
        return $this->sector($ctx, $u, sprintf(
            '|10The seedship blooms. |15%s|10 (%s) is yours - ferry colonists from Gaia.',
            \Atdt\Pipe::escape($name),
            $class
        ));
    }

    // ---- Prime Station (§15.2) ------------------------------------------

    private function dock(DoorContext $ctx, Universe $u, array $p, string $msg = ''): array
    {
        if ((int)$p['sector'] !== 1) {
            return $this->sector($ctx, $u, '|11Prime Station holds the only public dock - sector 1.');
        }
        $o = $ctx->out()->cls();
        $o->at(2, 1)->txt($ctx->center($ctx->pipe('|14P R I M E   S T A T I O N')));
        $o->at(3, 1)->fg(8)->txt($ctx->center(str_repeat('─', 50)))->reset();
        $o->at(5, 10)->txt($ctx->pipe(sprintf('|07Credits |15%s |08· |07bank |15%s |08(0.5%%/day interest, raid-proof)',
            number_format((int)$p['credits']), number_format((int)$p['bank']))));
        $row = 7;
        foreach ([
            sprintf('|08[|14H|08] |07Cargo hold      |15%d|07 cr  |08(you: %d, max %d)', self::HOLD_PRICE, (int)$p['holds'], self::MAX_HOLDS),
            sprintf('|08[|14F|08] |07Fighter         |15%d|07 cr  |08(you: %d)', self::FIGHTER_PRICE, (int)$p['fighters']),
            sprintf('|08[|14S|08] |07Shield          |15%d|07 cr  |08(you: %d, max %d)', self::SHIELD_PRICE, (int)$p['shields'], self::MAX_SHIELDS),
            sprintf('|08[|14E|08] |07Seedship        |15%s|07 cr  |08(%s)', number_format(self::SEEDSHIP_PRICE), (int)$p['has_seedship'] === 1 ? 'one aboard' : 'none'),
            '|08[|14B|08] |07Bank counter    |08deposit/withdraw',
            '|08[|14C|08] |07Cantina         |08rumors and gossip',
            '|08[|14R|08] |07Registrar       |08the rankings ladder',
            '|08[|14Q|08] |07Back to the sector view',
        ] as $lineTxt) {
            $o->at($row++, 14)->txt($ctx->pipe($lineTxt));
        }
        if ($msg !== '') {
            $o->at(17, 1)->txt($ctx->center($ctx->pipe($msg)));
        }
        $o->at(19, 1)->txt($ctx->center($ctx->pipe('|03Your business |08: |07')));
        $ctx->state['mode'] = 'dock';
        return [$ctx->ansi($o->reset()->str()), $ctx->hotkey()];
    }

    private function dockKey(DoorContext $ctx, Universe $u, string $key): array
    {
        $p = $this->player($ctx, $u);
        $buy = function (string $field, int $price, int $max, string $label) use ($ctx, $u, $p): array {
            if ((int)$p[$field] >= $max) {
                return $this->dock($ctx, $u, $p, '|12The yard cannot fit more ' . $label . 's on a Wren.');
            }
            if ((int)$p['credits'] < $price) {
                return $this->dock($ctx, $u, $p, '|12Short on credits for a ' . $label . '.');
            }
            $p['credits'] = (int)$p['credits'] - $price;
            $p[$field] = (int)$p[$field] + 1;
            $this->save($u, $p);
            return $this->dock($ctx, $u, $p, '|10One ' . $label . ' fitted.');
        };
        return match ($key) {
            'H' => $buy('holds', self::HOLD_PRICE, self::MAX_HOLDS, 'hold'),
            'F' => $buy('fighters', self::FIGHTER_PRICE, PHP_INT_MAX, 'fighter'),
            'S' => $buy('shields', self::SHIELD_PRICE, self::MAX_SHIELDS, 'shield'),
            'E' => (function () use ($ctx, $u, $p): array {
                if ((int)$p['has_seedship'] === 1) {
                    return $this->dock($ctx, $u, $p, '|11One seedship per ship - deploy it first.');
                }
                if ((int)$p['credits'] < self::SEEDSHIP_PRICE) {
                    return $this->dock($ctx, $u, $p, '|12A seedship runs ' . number_format(self::SEEDSHIP_PRICE) . ' credits.');
                }
                $p['credits'] = (int)$p['credits'] - self::SEEDSHIP_PRICE;
                $p['has_seedship'] = 1;
                $this->save($u, $p);
                return $this->dock($ctx, $u, $p, '|10A seedship folds itself into your aft bay.');
            })(),
            'B' => $this->prompt($ctx, 'bank', '|03Bank |08(+500 deposits, -500 withdraws)|03: |15', 8),
            'C' => $this->cantina($ctx, $u),
            'R' => $this->rankings($ctx, $u),
            'Q', 'ESC' => $this->sector($ctx, $u),
            default => [$ctx->bell()],
        };
    }

    private function bank(DoorContext $ctx, Universe $u, string $line): array
    {
        $p = $this->player($ctx, $u);
        if (!preg_match('/^\s*([+-])\s*(\d{1,9})\s*$/', $line, $m)) {
            return $this->dock($ctx, $u, $p, '|12The teller blinks. |15+500|12 deposits, |15-500|12 withdraws.');
        }
        $amt = (int)$m[2];
        if ($m[1] === '+') {
            $amt = min($amt, (int)$p['credits']);
            $p['credits'] = (int)$p['credits'] - $amt;
            $p['bank'] = (int)$p['bank'] + $amt;
        } else {
            $amt = min($amt, (int)$p['bank']);
            $p['bank'] = (int)$p['bank'] - $amt;
            $p['credits'] = (int)$p['credits'] + $amt;
        }
        $this->save($u, $p);
        return $this->dock($ctx, $u, $p, '|10The ledger updates. |07Banked credits sleep through raids.');
    }

    private function cantina(DoorContext $ctx, Universe $u): array
    {
        $o = $ctx->out()->cls();
        $o->at(2, 1)->txt($ctx->center($ctx->pipe('|13THE LAST PARSEC |08· |07cantina')));
        $o->at(3, 1)->fg(8)->txt($ctx->center(str_repeat('─', 50)))->reset();
        $row = 5;
        $o->at($row++, 6)->txt($ctx->pipe('|03Word from the board:'));
        $rumors = $ctx->boardRumors(4);
        if ($rumors === []) {
            $o->at($row++, 8)->txt($ctx->pipe('|08The bartender has nothing tonight.'));
        }
        foreach ($rumors as $r) {
            $o->at($row++, 8)->txt($ctx->pipe('|03· |07' . \Atdt\Pipe::escape(mb_strimwidth($r, 0, 66))));
        }
        $row++;
        $o->at($row++, 6)->txt($ctx->pipe('|03Game gossip:'));
        $events = $u->slog()->rows([], 'id DESC', 5);
        if ($events === []) {
            $o->at($row++, 8)->txt($ctx->pipe('|08Quiet out there. Too quiet.'));
        }
        foreach ($events as $e) {
            $o->at($row++, 8)->txt($ctx->pipe('|08' . $ctx->localStamp((string)$e['at'], 'm-d') . ' |07'
                . \Atdt\Pipe::escape((string)$e['body'])));
        }
        $o->at(22, 1)->txt($ctx->center($ctx->pipe('|07Any key drains the glass.')));
        $ctx->state['mode'] = 'pause_dock';
        return [$ctx->ansi($o->reset()->str()), $ctx->hotkey()];
    }

    // ---- scan & computer -------------------------------------------------

    private function scan(DoorContext $ctx, Universe $u, array $p): array
    {
        if ((int)$p['turns'] <= 0) {
            return $this->sector($ctx, $u, '|12No turns left to burn on the scanner.');
        }
        $p['turns'] = (int)$p['turns'] - 1;
        $this->save($u, $p);
        $o = $ctx->out()->cls();
        $o->at(2, 1)->txt($ctx->center($ctx->pipe('|11LONG-RANGE SCAN |08from sector |15' . (int)$p['sector'])));
        $o->at(3, 1)->fg(8)->txt($ctx->center(str_repeat('─', 50)))->reset();
        $row = 5;
        foreach (array_slice($u->warpsOut((int)$p['sector']), 0, 9) as $to) {
            $port = $u->ports()->one(['sector' => $to]);
            $bits = [];
            if ($port !== null) {
                $bits[] = 'port ' . Universe::classLabel((int)$port['pclass']);
            }
            foreach ($u->planets()->rows(['sector' => $to]) as $pl) {
                $bits[] = 'planet ' . (string)$pl['pname']
                    . ((int)$pl['fighters'] > 0 ? ' (|12' . (int)$pl['fighters'] . 'f|07)' : '');
            }
            if ($u->drifters()->count(['sector' => $to]) > 0) {
                $bits[] = '|14derelict contact|07';
            }
            foreach ($u->marauders()->rows(['sector' => $to]) as $mar) {
                $bits[] = '|12pirate "' . (string)$mar['name'] . '"|07';
            }
            $ships = 0;
            foreach ($u->players()->rows(['sector' => $to]) as $other) {
                if ((int)$other['user_id'] !== $ctx->userId()) {
                    $ships++;
                }
            }
            if ($ships > 0) {
                $bits[] = $ships . ' ship' . ($ships === 1 ? '' : 's');
            }
            $o->at($row++, 8)->txt($ctx->pipe(sprintf(
                '|03Sector |15%-5d |07%s',
                $to,
                $bits === [] ? '|08empty space' : implode(' |08·|07 ', $bits)
            )));
        }
        $o->at(20, 1)->txt($ctx->center($ctx->pipe('|08One turn spent. |07Any key returns.')));
        $ctx->state['mode'] = 'pause';
        return [$ctx->ansi($o->reset()->str()), $ctx->hotkey()];
    }

    private function computer(DoorContext $ctx, Universe $u, array $p, string $msg = ''): array
    {
        $known = $u->visits()->rows(['player' => $ctx->userId()], 'sector ASC');
        $o = $ctx->out()->cls();
        $o->at(2, 1)->txt($ctx->center($ctx->pipe('|11SHIP COMPUTER |08· |07known universe: |15' . count($known) . '|07 sectors')));
        $o->at(3, 1)->fg(8)->txt($ctx->center(str_repeat('─', 56)))->reset();
        $row = 5;
        $ports = [];
        foreach ($known as $v) {
            $seen = json_decode((string)$v['seen'], true);
            if (!is_array($seen) || !isset($seen['port']) || $seen['port'] === null) {
                continue;
            }
            $ports[] = ['sector' => (int)$v['sector'], 'at' => (string)$v['at'], 'port' => $seen['port']];
        }
        foreach (array_slice($ports, -12) as $k) {
            $o->at($row++, 6)->txt($ctx->pipe(sprintf(
                '|03%-5d |15%-16s |07%s |08· ore %d bio %d tech %d · seen %s',
                $k['sector'],
                mb_strimwidth((string)$k['port']['name'], 0, 16),
                Universe::classLabel((int)$k['port']['class']),
                (int)$k['port']['ore'],
                (int)$k['port']['bio'],
                (int)$k['port']['tech'],
                $ctx->localStamp($k['at'], 'm-d H:i')
            )));
        }
        if ($ports === []) {
            $o->at($row++, 6)->txt($ctx->pipe('|08No ports charted yet. Fly and they will come.'));
        }
        if ($msg !== '') {
            $o->at(19, 1)->txt($ctx->center($ctx->pipe($msg)));
        }
        $o->at(21, 1)->txt($ctx->center($ctx->pipe('|07Route finder: |15from to|07 (e.g. |151 42|07). Empty line returns.')));
        $ctx->state['mode'] = 'route';
        $prompt = $ctx->pipe('|03Plot |08: |15');
        $o->at(22, 30)->txt($prompt);
        return [$ctx->ansi($o->reset()->str()), $ctx->line(37, 22, 11)];
    }

    private function plotRoute(DoorContext $ctx, Universe $u, string $line): array
    {
        $p = $this->player($ctx, $u);
        if (!preg_match('/^\s*(\d{1,5})\s+(\d{1,5})\s*$/', $line, $m)) {
            return $this->computer($ctx, $u, $p, '|12Two sector numbers, like |151 42|12.');
        }
        $known = $this->knownSectors($ctx, $u);
        $path = $u->route((int)$m[1], (int)$m[2], $known);
        if ($path === null) {
            return $this->computer($ctx, $u, $p, '|12No route through KNOWN space. Scan and fly more of it.');
        }
        return $this->computer($ctx, $u, $p, '|10Route: |15' . (int)$m[1] . ' > ' . implode(' > ', $path)
            . ' |08(' . count($path) . ' turns)');
    }

    // ---- combat (§15.3) --------------------------------------------------

    private function attackMenu(DoorContext $ctx, Universe $u, array $p): array
    {
        if ($u->cfg('combat', 1) !== 1) {
            return $this->sector($ctx, $u, '|11The sysop has combat switched off. Peace in our time.');
        }
        $sid = (int)$p['sector'];
        if ($sid <= Universe::CHARTER_MAX) {
            return $this->sector($ctx, $u, '|11Charter Space is patrolled. No fights inside sector 9.');
        }
        $targets = $u->marauders()->count(['sector' => $sid]);
        foreach ($u->planets()->rows(['sector' => $sid]) as $pl) {
            if ((int)$pl['owner'] !== 0 && (int)$pl['owner'] !== $ctx->userId()) {
                $targets++;
            }
        }
        foreach ($u->players()->rows(['sector' => $sid]) as $other) {
            if ((int)$other['user_id'] !== $ctx->userId()) {
                $targets++;
            }
        }
        if ($targets === 0) {
            return $this->sector($ctx, $u, '|11Nothing here worth shooting. Scan for pirate contacts.');
        }
        if ((int)$p['fighters'] < 1) {
            return $this->sector($ctx, $u, '|12No fighters aboard. Prime Station sells them.');
        }
        return $this->prompt($ctx, 'attack', '|03Commit fighters |08(you have ' . (int)$p['fighters'] . ')|03: |15', 6);
    }

    private function attack(DoorContext $ctx, Universe $u, string $line): array
    {
        $p = $this->player($ctx, $u);
        $commit = min((int)trim($line), (int)$p['fighters']);
        if ($commit < 1) {
            return $this->sector($ctx, $u, '|11The squadron stands down.');
        }
        $sid = (int)$p['sector'];
        // Ships first (the classic grudge), then pirates, then planets.
        foreach ($u->players()->rows(['sector' => $sid]) as $other) {
            if ((int)$other['user_id'] !== $ctx->userId()) {
                return $this->shipFight($ctx, $u, $p, $other, $commit);
            }
        }
        $mar = $u->marauders()->one(['sector' => $sid]);
        if ($mar !== null) {
            return $this->marauderFight($ctx, $u, $p, $mar, $commit);
        }
        foreach ($u->planets()->rows(['sector' => $sid]) as $pl) {
            if ((int)$pl['owner'] !== 0 && (int)$pl['owner'] !== $ctx->userId()) {
                return $this->planetFight($ctx, $u, $p, $pl, $commit);
            }
        }
        return $this->sector($ctx, $u, '|11The targets slipped away.');
    }

    /** Waves of 1:1 trades, 55% attacker edge (§15.3). */
    private function waves(int $attackers, int $defenders): array
    {
        while ($attackers > 0 && $defenders > 0) {
            if (random_int(1, 100) <= 55) {
                $defenders--;
            } else {
                $attackers--;
            }
        }
        return [$attackers, $defenders];
    }

    private function shipFight(DoorContext $ctx, Universe $u, array $p, array $victim, int $commit): array
    {
        $defence = (int)$victim['fighters'] + (int)$victim['shields'] * 2;
        [$aLeft, $dLeft] = $this->waves($commit, $defence);
        $p['fighters'] = (int)$p['fighters'] - ($commit - max(0, $aLeft));
        if ($dLeft > 0) {
            // The defence holds: give back what survives of it.
            $shields = min((int)$victim['shields'], intdiv($dLeft, 2));
            $u->players()->update(
                ['fighters' => max(0, $dLeft - $shields * 2), 'shields' => $shields],
                ['user_id' => (int)$victim['user_id']]
            );
            $this->save($u, $p);
            $u->notify((int)$victim['user_id'], $ctx->handle() . ' hit your ship in sector ' . (int)$p['sector'] . ' - your defence held.');
            $u->logEvent($ctx->handle() . ' bounced off ' . (string)$victim['handle'] . ' in sector ' . (int)$p['sector'] . '.');
            return $this->sector($ctx, $u, '|12Their defence holds. |07You lost ' . ($commit - max(0, $aLeft)) . ' fighters.');
        }
        // Kill: pod the victim home, scoop what fits, split their wallet.
        $loot = intdiv((int)$victim['credits'], 2);
        $p['credits'] = (int)$p['credits'] + $loot;
        foreach (Universe::GOODS as $g) {
            $take = min((int)$victim[$g], $this->freeHolds($p));
            $p[$g] = (int)$p[$g] + $take;
        }
        $p['kills'] = (int)$p['kills'] + 1;
        $this->save($u, $p);
        $u->players()->update([
            'sector' => 1, 'holds' => 20, 'fighters' => 0, 'shields' => 0,
            'ore' => 0, 'bio' => 0, 'tech' => 0, 'colonists' => 0,
            'credits' => (int)$victim['credits'] - $loot,
            'deaths' => (int)$victim['deaths'] + 1,
        ], ['user_id' => (int)$victim['user_id']]);
        $u->notify(
            (int)$victim['user_id'],
            $ctx->handle() . ' destroyed your ship in sector ' . (int)$p['sector']
            . '. The escape pod reached Prime Station; your bank never noticed.'
        );
        $u->logEvent($ctx->handle() . ' turned ' . (string)$victim['handle'] . "'s ship to glitter in sector " . (int)$p['sector'] . '.');
        $ctx->award(50);
        return $this->sector($ctx, $u, '|10Their hull gives. |07You scoop |15' . number_format($loot) . '|07 credits from the wreck.');
    }

    /** Pirates fight like ships but pay a bounty and never pod YOU. */
    private function marauderFight(DoorContext $ctx, Universe $u, array $p, array $mar, int $commit): array
    {
        $defence = (int)$mar['fighters'] + (int)$mar['shields'] * 2;
        [$aLeft, $dLeft] = $this->waves($commit, $defence);
        $p['fighters'] = (int)$p['fighters'] - ($commit - max(0, $aLeft));
        if ($dLeft > 0) {
            $shields = min((int)$mar['shields'], intdiv($dLeft, 2));
            $u->marauders()->update(
                ['fighters' => max(1, $dLeft - $shields * 2), 'shields' => $shields],
                ['id' => (int)$mar['id']]
            );
            // A repulsed raid gets boarded on the way out: 10% of the wallet.
            $tax = intdiv((int)$p['credits'], 10);
            $p['credits'] = (int)$p['credits'] - $tax;
            $this->save($u, $p);
            return $this->sector($ctx, $u, sprintf(
                '|12The "%s" holds and boards you for |15%s|12 credits. |07You lost %d fighters.',
                (string)$mar['name'],
                number_format($tax),
                $commit - max(0, $aLeft)
            ));
        }
        $u->marauders()->delete(['id' => (int)$mar['id']]);
        $p['credits'] = (int)$p['credits'] + (int)$mar['bounty'];
        $p['kills'] = (int)$p['kills'] + 1;
        $this->save($u, $p);
        $u->logEvent($ctx->handle() . ' collected the bounty on the "' . (string)$mar['name'] . '".');
        $ctx->award(30);
        return $this->sector($ctx, $u, sprintf(
            '|10The "%s" breaks apart. |07Bounty paid: |15%s|07 credits.',
            (string)$mar['name'],
            number_format((int)$mar['bounty'])
        ));
    }

    private function planetFight(DoorContext $ctx, Universe $u, array $p, array $pl, int $commit): array
    {
        [$aLeft, $dLeft] = $this->waves($commit, (int)$pl['fighters']);
        $p['fighters'] = (int)$p['fighters'] - ($commit - max(0, $aLeft));
        if ($dLeft > 0) {
            $u->planets()->update(['fighters' => $dLeft], ['id' => (int)$pl['id']]);
            $this->save($u, $p);
            $u->notify((int)$pl['owner'], $ctx->handle() . ' raided ' . (string)$pl['pname'] . ' - the garrison held (' . $dLeft . ' left).');
            return $this->sector($ctx, $u, '|12The garrison holds at ' . $dLeft . '. |07You lost ' . ($commit - max(0, $aLeft)) . '.');
        }
        $u->planets()->update(
            ['owner' => $ctx->userId(), 'owner_handle' => $ctx->handle(), 'fighters' => max(0, $aLeft - 1)],
            ['id' => (int)$pl['id']]
        );
        $p['fighters'] = max(0, $aLeft > 0 ? (int)$p['fighters'] : (int)$p['fighters']);
        $this->save($u, $p);
        $u->notify((int)$pl['owner'], $ctx->handle() . ' took ' . (string)$pl['pname'] . ' from you, colonists and all.');
        $u->logEvent($ctx->handle() . ' planted a new flag on ' . (string)$pl['pname'] . '.');
        $ctx->award(75);
        return $this->sector($ctx, $u, '|10The flag comes down. |15' . \Atdt\Pipe::escape((string)$pl['pname']) . '|10 is yours.');
    }

    // ---- beacons, rankings ----------------------------------------------

    private function setBeacon(DoorContext $ctx, Universe $u, string $line): array
    {
        $p = $this->player($ctx, $u);
        $text = trim(\Atdt\Chat::clean($line));
        if ($text === '') {
            return $this->sector($ctx, $u, '|11The old beacon keeps blinking.');
        }
        if ((int)$p['credits'] < self::BEACON_PRICE) {
            return $this->sector($ctx, $u, '|12A beacon casing runs ' . self::BEACON_PRICE . ' credits.');
        }
        $p['credits'] = (int)$p['credits'] - self::BEACON_PRICE;
        $this->save($u, $p);
        $u->sectors()->update(
            ['beacon' => mb_strimwidth($text, 0, 60), 'beacon_owner' => $ctx->handle()],
            ['sid' => (int)$p['sector']]
        );
        return $this->sector($ctx, $u, '|10The beacon takes your words and starts to blink.');
    }

    private function rankings(DoorContext $ctx, Universe $u): array
    {
        $all = $u->players()->rows([], 'user_id ASC');
        $ladder = [];
        foreach ($all as $pl) {
            $ladder[] = ['handle' => (string)$pl['handle'], 'worth' => $u->netWorth($pl),
                'kills' => (int)$pl['kills'], 'deaths' => (int)$pl['deaths']];
        }
        usort($ladder, fn(array $a, array $b) => $b['worth'] <=> $a['worth']);
        $o = $ctx->out()->cls();
        $o->at(2, 1)->txt($ctx->center($ctx->pipe('|14THE MERCHANT REGISTRY |08· |07net worth ladder')));
        $o->at(3, 1)->fg(8)->txt($ctx->center(str_repeat('─', 52)))->reset();
        $o->at(5, 14)->txt($ctx->pipe('|03  # Merchant            Net worth   Kills  Pods'));
        $row = 7;
        foreach (array_slice($ladder, 0, 10) as $i => $r) {
            $o->at($row++, 14)->txt($ctx->pipe(sprintf(
                '|14%3d |15%-18s |07%11s |08%5d %5d',
                $i + 1,
                \Atdt\Pipe::escape(mb_strimwidth($r['handle'], 0, 18)),
                number_format($r['worth']),
                $r['kills'],
                $r['deaths']
            )));
        }
        $week = $u->store->get('hall_latest');
        if ($week !== null) {
            $o->at(19, 1)->txt($ctx->center($ctx->pipe('|08Hall of fame, week ' . $week . ': |07'
                . \Atdt\Pipe::escape((string)$u->store->get('hall:' . $week, '—')))));
        }
        $o->at(22, 1)->txt($ctx->center($ctx->pipe('|07Any key returns to the sector view.')));
        $ctx->state['mode'] = 'pause';
        return [$ctx->ansi($o->reset()->str()), $ctx->hotkey()];
    }

    // ---- input routing ---------------------------------------------------

    private function prompt(DoorContext $ctx, string $mode, string $piped, int $max): array
    {
        $ctx->state['mode'] = $mode;
        $prompt = $ctx->pipe($piped);
        $o = $ctx->out();
        $o->at(self::ROW_MSG, 1)->txt("\x1b[K")->at(self::ROW_MSG, 3)->txt($prompt);
        $plain = preg_replace('/\x1b\[[0-9;]*[A-Za-z]/', '', $prompt) ?? '';
        $col = 3 + mb_strlen($plain);
        return [$ctx->ansi($o->reset()->str()), $ctx->line($col, self::ROW_MSG, min($max, 80 - $col))];
    }

    private function lineEntered(DoorContext $ctx, Universe $u, ?string $line): array
    {
        $mode = (string)($ctx->state['mode'] ?? 'cmd');
        $ctx->state['mode'] = 'cmd';
        if ($line === null || trim($line) === '') {
            return $this->sector($ctx, $u);
        }
        return match ($mode) {
            'move' => $this->autopilot($ctx, $u, $this->player($ctx, $u), (int)trim($line)),
            'trade' => $this->trade($ctx, $u, $line),
            'bank' => $this->bank($ctx, $u, $line),
            'beacon' => $this->setBeacon($ctx, $u, $line),
            'attack' => $this->attack($ctx, $u, $line),
            'route' => $this->plotRoute($ctx, $u, $line),
            'name_planet' => $this->foundPlanet($ctx, $u, $line),
            default => $this->sector($ctx, $u),
        };
    }
}
