Музей, в котором есть другие люди
Первая версия была музеем для одного человека. Вторая — то же здание, но по нему ходят другие: четыре символа на входе, до восьми посетителей, голоса, затихающие, когда собеседник уходит дальше, — и ни одного байта всего этого на сервере.
На этой странице
Шесть недель назад я описал музей для всего, у чего есть ссылка — защищённый запрос, каскад разбора, планировку, которая никогда не двигается. Он работал, и это было здание ровно для одного человека. Можно было вставить ссылку, повесить вещь, побродить по собственным залам, — а дальше с построенным оставалось делать только одно: рассказывать о нём кому-нибудь.
Поэтому вторая версия — тот же музей, но с людьми внутри. Вы открываете комнату, зачитываете четыре символа, и в ваше здание входит ещё до семи посетителей — фигурами, которые видно через галерею, с голосами, звучащими тише по мере ухода в соседний зал. Само здание за это время тоже стало лучше: три типа галереи вместо одного, скульптура на постаментах, небо, меняющееся день ото дня, и экскурсия, ставшая наконец настоящим контроллером камеры.
Ничего из этого нигде не хранится. Это ограничение сделало больше проектной работы, чем любая функция, — с него и начнём.
- 8
- посетителей в комнате
- 0
- байт хранится на сервере
- 246
- строк интерфейса ×6 локалей
- 303
- юнит-теста
Рабочая форма Reliquary v2. Второе число — то, с которым вышла v1; сохранить его, впустив в здание других людей, и есть смысл почти всего, что следует дальше.
Комната — это канал, а не запись
Очевидный способ построить совместные визиты — таблица комнат: строка на комнату, список участников, история чата. Это же и точка, в которой ваш музей никогда не покидает браузер тихо превращается в …кроме тех частей, которые мы теперь храним. Поэтому записи о комнате нет нигде. Комната — это топик канала, vt:reliquary:v1:<CODE>, на широковещательном ретрансляторе, и браузер хозяина и есть комната. Она живёт, пока ключ присутствия хозяина сидит в канале; закрыл вкладку — всё кончилось.
У отсутствия записи есть последствие, которого я не предвидел: спросить, существует ли комната, невозможно. Топик, на котором никого нет, подписывается ровно так же охотно, как и оживлённый. Поэтому на вопрос отвечают присутствие и дедлайн: входящий гость ждёт шесть секунд появления ключа хозяина, и этот таймаут и есть определение «такой комнаты нет». А до того, как хозяин займёт код, то же отсутствие заставляет его посидеть на топике 900 мс и посмотреть, не живёт ли там уже кто-то.
Код — четыре символа, показывается как REL-7K2M. В алфавите тридцать знаков: нет 0 и O, нет 1, I и L, нет U — первые две пары дают классическую путаницу при чтении вслух, а U убран, чтобы никакой розыгрыш не сложился в слово, которое посетителю не захочется произносить вслух. Тридцать в четвёртой — это 810,000 кодов, берутся из crypto.getRandomValues. Это приватность через неизвестность, и она названа своим именем: угаданный код — единственная дверь в чужой музей, поэтому у хозяина есть кнопка закрытия комнаты, а каждый приходящий байт всё равно проверяется заново.
Ретранслятор без полномочий
Учётные данные, которые пускают браузер в этот канал, публичны по замыслу — маркетинговые страницы и так отдают их каждому посетителю. Честное описание транспорта: тупая труба, в которую любой, у кого есть четыре символа, может отправить что угодно, и всё ниже по течению построено на этом допущении.
Каждое сообщение проверяется заново при получении — по типу и по границам — в одной функции, которая и есть вся граница доверия этой возможности. Экспонаты, пришедшие от собеседника, снова проходят через sanitizeExhibit, ту же самую функцию, которой фильтруется собственное хранилище посетителя: она импортируется, а не копируется, чтобы не разъехаться. URL, отвергнутый с диска, отвергается и с провода. Идентификатор отправителя — это заявление, а не удостоверение, и слоя подписи здесь намеренно нет, в отличие от протокола 4D-шахмат, где подделанный ход мог бы украсть партию. Здесь красть нечего.
Отправитель никогда не бывает санитайзером. Монтирование от гостя — это просьба; хозяин очищает её заново, применяет через собственное хранилище и рассылает то, что действительно легло.
Защищаться пришлось от усиления. Тридцатибайтный hello, покупающий ресинхронизацию на шестьдесят экспонатов, — это рычаг, направленный на комнату, поэтому у каждого сообщения, ответ на которое стоит дороже самого сообщения, есть порог на отправителя: две секунды между повторными синхронизациями, 400 мс между строками чата, три секунды между запросами на монтирование. Печатать медленнее 400 мс; цикл — нет. Синхронизация нарезается по 48 КБ или 24 элемента, что наступит раньше, — и пустая коллекция всё равно выдаёт один фрагмент с пустым списком, потому что гость, не получивший ничего, не отличит «у хозяина ничего нет» от «синхронизация ещё идёт».
Позиция — единственное сообщение, которое повторяется: десять раз в секунду на посетителя, всё время, пока комната открыта, — поэтому она квантуется до сантиметров и миллирадиан. Это не только сжатие: квантователь заодно и детектор простоя, ведь две позы, квантующиеся в одни и те же целые числа, и есть одна поза, и стояние на месте останавливает вещание без отдельной проверки.
Восемь человек в шерсти и льне
В спецификации v1 сверху стояли две строки: никаких новых npm-зависимостей, никаких бинарных ассетов. Вторая и решила, что такое посетитель. Ни загрузок, ни скачиваемых персонажей — посетитель это четыре небольших целых числа и имя, и весь вывод примерочной умещается в полезной нагрузке присутствия.
Восемь палитр, названных по ткани, а не по цвету: сланец, кэмел, слива, мох, ржавчина, чернила, лён, патина. Словарь галереи — орех, известковая побелка и латунь под низким солнцем, и каждый наряд лежит внутри этого диапазона, а не спорит с ним. Ровно одна палитра тянется к бирюзе, на две ступени ниже по насыщенности: посетитель в пальто с бирюзинкой, а не источник света, идущий через зал. Бирюза здесь по-прежнему скальпель.
Четыре формы головы — одна кривая. Суперэллипс — |y/ry|^n + |x/rx|^n = 1 — при n = 2 даёт эллипс и приближается к прямоугольнику с ростом n; круглая, овальная, квадратная и гребень — четыре набора его параметров, квадратная голова на 3.6, мягкий куб вращения, а не коробка с углами. Одна функция выдаёт силуэт, трёхмерная фигура вытачивает его на токарном станке, плоское превью в примерочной отражает его в контур, — так что выбор не может пообещать голову, которую фигура не сохранит. Рост — узкая полоса, от 0.9× до 1.1× фигуры в 1.70 м.
Решение, с которым я ожидал спорить сам с собой, — столкновения. Собеседники здесь призраки: ни один аватар не даёт коллайдера, и контроллеру ходьбы вообще не сообщают об их существовании. То, что мы рисуем, — оценка, сглаженная пружиной догадка о том, где человек был пару сотен миллисекунд назад, а быть отпихнутым оценкой хуже, чем пройти сквозь того, кто действительно тут стоит. К тому же дверной проём шириной 2.3 м: один праздно стоящий в нём посетитель, будь он твёрдым, запечатает целое крыло чужого музея — и попросить его отойти нечем. Наложиться друг на друга — неловкость на две секунды; застрять — сломанный визит.
Голоса, затихающие, когда вы уходите
Текстовый чат занял утро. Голос — то, что превращает общую страницу в общую комнату. Звук идёт от браузера к браузеру по прямым соединениям; канал лишь передаёт двум посетителям непрозрачные блобы сессий друг друга. Именно это закрепляет потолок на восьми, включая хозяина: полная сетка из восьми — это 28 соединений и семь исходящих потоков на человека, примерно столько, сколько выдерживают аплинк ноутбука и кодировщик телефона. Поднять планку значило бы завести медиасервер, а не увеличить константу.
Оба конца пары узнают друг о друге из одного и того же события присутствия, в одно и то же мгновение, поэтому оба пытаются начать разговор и оба встают намертво — классический glare. Починка стоит одного сравнения: предлагает тот, чей идентификатор идёт раньше по порядку, второй ждёт. Идентификаторы — 32 шестнадцатеричных символа, так что порядок полный и одинаковый с обеих сторон. Голос каждый посетитель включает сам, поэтому отвечающая сторона, не услышавшая ничего за 2.5 секунды, повышает себя и предлагает первой.
Дальше то, что вы действительно слышите. Голос звучит на полной громкости в пределах двух метров — расстояние разговора — и дальше спадает по простому закону обратного расстояния. Физическая обратная зависимость, впрочем, никого не заглушает: на четырнадцати метрах голос всё ещё около 14%, а семь таких под фоновым гулом зала дают бормотание, звучащее как неисправность, а не как музей. Поэтому на последнем отрезке кривая загоняется ровно в ноль оконной функцией smoothstep, плоской на обоих концах, чтобы никто не щёлкнул, уходя. Залы здесь около десяти метров, так что затухание попадает между дальним концом этого зала и двумя залами отсюда.
Панорама — боковая составляющая пеленга, ограниченная ±0.8, и ближе метра она сходится к центру, чтобы два посетителя, проходящие сквозь друг друга, не хлестнули у вас в ушах слева направо.
Голос работает в собственном аудиоконтексте, а не одалживает контекст игрового движка, который сам приостанавливается, едва документ скрыт: верно для шагов, неверно для разговора. Он всё равно подчиняется той же кнопке отключения звука, потому что человек, заглушивший музей, имел в виду сделай тише, а не сделай тише перезвон. Push-to-talk сидит на V и глушит дорожку микрофона, а не соединение.
Остаток, названный, а не закопанный: релейного сервера пока нет. Два браузера, чьи сети отказываются встретиться напрямую, не соединятся никогда. Такая пара помечается в списке посетителей как недоступная и сохраняет текстовый чат — хуже работающего голоса и куда лучше вечно крутящегося кружка.
Визит без записи в ваше хранилище
Гость смотрит на чужую коллекцию, а обещание из заметки о запуске состоит в том, что ваш музей — один ключ в вашем собственном браузере. Поэтому визит рисует полностью отдельный мир, а не тот, что держит ваш ключ, и в ваше хранилище, пока вы в гостях, не пишут. Подмена едет под завесой в обе стороны, и завеса опускается немедленно, ещё до того, как приедет нарезанная синхронизация: иначе гость на мгновение оставался бы стоять в собственной галерее, гадая, чьи это картины.
Режим только для чтения оказался чередой мелких решений, а не одним переключателем. Панель управления сохраняет список и переход к экспонату, но удаления и очистки в ней больше нет. Строка вставки остаётся и становится просьбой: разбор по-прежнему происходит в браузере гостя, очищенный результат едет по проводу, хозяин очищает его ещё раз и отвечает вердиктом, который приходит всплывающим уведомлением. Когда монтирование выключено, строка отключена и говорит почему, а не исчезает: отсутствующий элемент управления неотличим от сломанной страницы.
Шов, который оставила первая версия
Заметка о запуске кончалась обещанием: режим экскурсии, «намеченный за тем же швом контроллера, которым пользуется режим ходьбы». Какое-то время эта экскурсия была тем, чем такие вещи обычно и становятся, — цепочкой одноразовых твинов камеры, запускаемых изнутри цикла отрисовки, а значит, у камеры было два хозяина в зависимости от минуты.
Теперь есть вторая реализация. Рельс — заранее вычисленный список остановок: каждая работа в порядке развески, каждая — в той фронтальной позе обзора, которой уже пользуется автопроход. Между остановками камера летит по ломаной из путевых точек в дверных проёмах, а не интерполируется сквозь стену, задерживается на 3.6 секунды и в конце заходит на новый круг. Обзор перетаскиванием во время поездки добавляет ограниченное смещение поверх собственного направления рельса — на ходу оно затухает обратно к центру, поэтому следующая работа всегда приезжает по центру, а на стоянке держится. Настоящий перехват — это касание клавиш движения, и оно возвращает вас к ходьбе в текущей позе. Лучшее следствие контроллера вместо анимации: открытие работы теперь ставит экскурсию на паузу, а не заканчивает её.
Миникарта берётся из того же модуля раскладки, что и здание: вход сверху, галереи идут вниз по панели в том порядке, в котором вы их проходите. Кликните по залу или по точке — и существующая машинерия «дойти или уйти под завесу» доставит вас туда. Другие посетители показаны точками в собственных акцентных цветах; единственная бирюза на панели — сектор, показывающий, куда вы смотрите.
Само здание стало лучше
У v1 был один рецепт галереи: длинный зал с работами, чередующимися по двум простым стенам. Это хороший зал, и к тридцатому экспонату он единственный, где вы вообще бывали. Теперь архетипов три, и они чередуются по индексу зала: длинный зал, салон (более широкий парадный зал с угловыми пилястрами под потолочным окулюсом) и галерея с нишами, где между развесками из боковых стен выступают неглубокие пилоны, так что каждая пара работ стоит в собственной нише.
Деликатным всё это делает инвариант, на котором держится здание целиком: добавление экспоната N+1 никогда не должно сдвигать экспонаты с 1 по N. Поэтому всё, что меняет архетип, — чистая функция индекса зала, а зал 0 намеренно остаётся длинным залом с исходными числами, так что каждый музей, построенный в июне, воспроизводит ровно ту геометрию, какая у него была. Экспонаты без изображения раньше висели табличкой на стене; теперь они стоят гравированной стелой на постаменте, ровно в той же позиции слота.
И небо пришло в движение. Три настроения — золотой час, бледное утро и сумерки синего часа — выбираются взвешенным колесом на десять слотов по хешу локальной календарной даты. Золотой час держит пять слотов из десяти, потому что это лицо дома; на весь день у вас одна погода, завтра другая, а два человека в одном часовом поясе видят один и тот же музей. Каждое настроение несёт весь контракт освещения, а не только небо: сумерки поднимают тёплое самосвечение штукатурки, а не опускают его, потому что до стены над дверным проёмом не достают ни низкое солнце, ни софиты работ.
Что вышло тихо
Перед монтированием canvas теперь запускается проба WebGL, и настоящее отсутствие получает локализованную заставку вместо пустого прямоугольника — именно настоящее отсутствие, никогда не класс устройства: телефон с работающим WebGL сохраняет весь проходимый музей. Потеря контекста заведена на состояние восстановления и чистое перемонтирование.
Работы — это меши, поэтому клавише Tab не за что зацепиться. Холсту с обзором перетаскиванием подходит другой приём — курсор по порядку развески: [ и ] переходят между работами, камера идёт к каждой пешком или телепортируется под завесой, и каждая вежливо объявляется. Enter рассматривает — а если курсор не задан, берётся работа, ближайшая к тому месту, где вы стоите.
И у музея теперь есть тесты: 303 в 11 файлах, вместо нуля. Это скорее архитектура, чем добродетель: протокол, коды комнат, математика голоса, раскладка и рельс — чистые модули без React, без three.js и без сети внутри, и поэтому вопрос почему голос этого человека звучит не с той стороны — тест, а не вечер в браузере. Интерфейс вырос с 87 строк до 246, во всех шести локалях, с проверкой паритета.
| v1 — июнь | v2 — сегодня | |
|---|---|---|
| Посетители | 1 | до 8, включая хозяина |
| Голос | — | сетка по расстоянию, напрямую между браузерами |
| Экскурсия | цепочка твинов камеры | контроллер-рельс; фокус ставит на паузу |
| Залы галереи | один рецепт зала | 3 архетипа, чередуются по индексу зала |
| Небо | золотой час | 3 настроения, по одному на календарный день |
| Лоты без изображения | табличка на стене | стела на постаменте |
| Ориентирование | ходьба или список в панели управления | миникарта-планировка с точками посетителей |
| Строки интерфейса | 87 ×6 локалей | 246 ×6 локалей |
| Юнит-тесты | 0 | 303 в 11 файлах |
| Хранится на сервере | ничего | по-прежнему ничего |
Последняя строка — тезис. Всё, что выше неё, — цена того, чтобы последняя строка осталась правдой, когда в здании больше одного человека.
Модель приватности в первой версии была слишком простой, чтобы её испортить: базы нет — терять нечего. Совместный режим обычно и есть то место, где это предложение получает поправку: нужна комната где-то, список участников где-то, немного истории, чтобы чат прокручивался. Оказывается, не нужна. Нужны канал, код, достаточно короткий, чтобы произнести его вслух, и дисциплина проверять заново каждый приходящий байт — потому что ретранслятор без полномочий безопасен только при условии, что ниже по течению ему никто не верит.
Чего я не ожидал — насколько побочно улучшилось само здание. Как только в комнате может оказаться кто-то ещё, замечаешь, что все галереи одинаковы, что небо не меняется, что лоты без картинки извиняются за себя перед стеной. В одиночку это никого не беспокоит. Видно это только тогда, когда в этом стоит кто-то другой.