본문으로 이동

사용자:Hsl0/연구소/숫자야구 live/실시간.js

리버티게임, 모두가 만들어가는 자유로운 게임

참고: 설정을 저장한 후에 바뀐 점을 확인하기 위해서는 브라우저의 캐시를 새로 고쳐야 합니다.

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
var _a, _b, _c, _d, _e, _f;
const TIMEOUT = Symbol('timeout_id');
const STOPPED = Symbol('is_stopped');
const RUNNING = Symbol('is_running');
const THROTTLE = Symbol('throttle_timeout_id');
const DOWN_START = Symbol('process_start_time');
const REQUEST = Symbol('request_event_handler');
const FETCH = Symbol('fetch_event_handler');
const CLIENTS = Symbol('clients_list');
function parseJSON(text, reviver) {
    try {
        return JSON.parse(text, reviver);
    }
    catch (error) {
        return text;
    }
}
/** 실시간 클라이언트 */
export class LiveClient extends EventTarget {
    /**
     * LiveClient 객체 생성자
     * @param title 편집 대상 문서
     * @param options 추가 설정 객체
     */
    constructor(title, options = {}) {
        var _g;
        if (!title)
            throw new TypeError('문서를 지정해 주세요');
        super();
        /** 시작 리비전 */
        this.now = null;
        /** 자동 요청 실행 여부 */
        this[_a] = false;
        /** 정지 예약 여부, 다음 이벤트 무시 */
        this[_b] = false;
        /** 쓰로틀 해제 코드 */
        this[_c] = null;
        /** 필수 대기 해제 코드 */
        this[_d] = null;
        this.target = title;
        this.api = options.api || this.constructor.api;
        this.interval = {
            ...this.constructor.interval,
            ...options.interval,
        };
        this.loadParams = {
            ...this.constructor.loadParams,
            titles: this.target,
            ...options.loadParams,
        };
        this.postParams = {
            ...this.constructor.postParams,
            title: this.target,
            ...options.postParams,
        };
        this.loadAjax = {
            ...this.constructor.loadAjax,
            ...options.loadAjax,
        };
        this.postAjax = {
            ...this.constructor.postAjax,
            ...options.postAjax,
        };
        this.now = options.from || null;
        this.stopOnError = (_g = options.stopOnError) !== null && _g !== void 0 ? _g : true;
    }
    /**
     * 1회 불러오기
     * @param from 시작 리비전
     * @param options 추가 파라미터 및 AJAX 설정
     * @returns 원본 응답, 리비전, 시작 리비전, 추가 요청 가능 여부를 묶은 객체
     */
    loadOnce(from, { params, ajax } = {}) {
        from !== null && from !== void 0 ? from : (from = this.now);
        // 시작 리비전
        return this.api
            .get({ ...from, ...this.loadParams, ...params }, { ...this.loadAjax, ...ajax })
            .then((response, xhr) => {
            var _g;
            if (response.error) {
                throw {
                    type: response.error.code,
                    info: response.error.info,
                    xhr: xhr,
                    target: from || null,
                    response: response,
                    exception: null,
                };
            }
            else if (response.query) {
                const revisions = (_g = response.query.pages[0]) === null || _g === void 0 ? void 0 : _g.revisions;
                const result = {
                    response,
                    revisions,
                    target: from,
                    more: Boolean(response.continue),
                };
                return result;
            }
            else
                throw new TypeError('API 결과를 받지 못했습니다');
        }, (type, info, response, xhr) => {
            throw {
                type: type === 'http' &&
                    info.textStatus !== 'error'
                    ? info.textStatus
                    : type,
                info: response === null || response === void 0 ? void 0 : response.error.info,
                xhr: xhr || info,
                target: from,
                response: response || null,
                exception: xhr ? null : info.exception,
            };
        });
    }
    /**
     * 끝까지 불러오기, 마지막까지 불러오면 종료
     * @param from 시작 리비전
     * @param options 추가 파라미터 및 AJAX 설정
     */
    async *loadAll(from = this.now, { params, ajax } = {}) {
        let result;
        do {
            result = await this.loadOnce(from, { params, ajax });
            yield result;
        } while (result.more);
    }
    /**
     * 무한 반복 불러오기 코루틴
     * @param from 시작 리비전
     * @param globalOptions 추가 파라미터 및 AJAX 설정
     */
    async *load(from = this.now, globalOptions = {}) {
        let options = globalOptions;
        while (true) {
            const result = await this.loadOnce(from, options);
            if (result === null || result === void 0 ? void 0 : result.revisions) {
                // 이번 요청에서 데이터가 갱신되었을 때
                if (result.more) {
                    // 더 불러올 데이터가 있으면
                    from = result.response.continue;
                }
                else {
                    // 더 불러올 데이터가 없으면
                    const last = result.revisions[result.revisions.length - 1];
                    from = {
                        rvstartid: last.revid + 1,
                        rvstart: last.timestamp,
                    };
                }
            }
            options = { ...globalOptions, ...(yield result) };
        }
    }
    /**
     * 데이터 전송
     * @param content 내용
     * @param options 추가 파라미터 및 AJAX 설정
     * @returns 업로드 결과
     */
    post(content, { params, ajax } = {}) {
        return this.api.postWithEditToken({
            ...this.postParams,
            ...params,
            text: content,
        }, { ...this.postAjax, ...ajax });
    }
    /**
     * 계속 불러오기(간격 재설정)
     * @param options 간격 및 시작 리비전 설정
     * @returns 이 객체 (메소드 체이닝)
     */
    start({ interval = {}, from = this.now, } = {}) {
        Object.assign(this.interval, interval); // 시간 간격 갱신
        if (typeof this.interval !== 'object' ||
            (this.interval.throttle &&
                typeof this.interval.throttle !== 'number') ||
            (this.interval.margin && typeof this.interval.margin !== 'number')) {
            throw new TypeError('정상적인 간격이 지정되지 않았습니다');
        }
        const generator = this.load(from);
        const load = (options) => {
            // 다음 폴링
            const next = () => {
                if (throttle && request)
                    this[TIMEOUT] = setTimeout(load, this.interval.margin || 0, {
                        params: {
                            maxage: 0,
                        },
                    });
            };
            let throttle = false; // 최소 시간 경과 여부
            let request = false; // 요청 완료 여부
            this[TIMEOUT] = null;
            this[RUNNING] = true;
            // 일정 시간이 지나기 전에는 다음 요청을 하지 않음
            this[THROTTLE] = setTimeout(() => {
                if (this[STOPPED])
                    this[STOPPED] = false;
                else {
                    throttle = true;
                    this[THROTTLE] = null;
                    next();
                }
            }, this.interval.throttle || 0);
            // AJAX 요청
            generator.next(options).then(({ value: result }) => {
                if (this[STOPPED])
                    this[STOPPED] = false;
                // 정지가 예약되면 이번 이벤트를 무시하고 정지 예약 해제
                else {
                    const { response, revisions } = result;
                    result.error = false;
                    result.updated = Boolean(revisions);
                    // 로드 완료
                    super.dispatchEvent(new CustomEvent('fetch', {
                        detail: result,
                    }));
                    if (revisions) {
                        // 이번 요청에서 데이터가 갱신되었을 때
                        super.dispatchEvent(new CustomEvent('update', {
                            detail: result,
                        }));
                        if (response.continue) {
                            // 더 불러올 데이터가 있으면 바로 요청
                            return load();
                        }
                    }
                    request = true; // 데이터 불러옴
                    next(); // 다음 요청 시도
                }
            }, (error) => {
                if (this[STOPPED])
                    this[STOPPED] = false;
                // 정지가 예약되면 이번 이벤트를 무시하고 정지 예약 해제
                else {
                    // 에러 이벤트
                    super.dispatchEvent(new CustomEvent('error', {
                        detail: error,
                    }));
                    // 서버에 요청이 정상적으로 보내졌으나 API 에러가 발생한 경우 (넷코드용)
                    if (error.type === 'api')
                        super.dispatchEvent(new CustomEvent('fetch', {
                            detail: {
                                response: error.response,
                                error: true,
                                updated: false,
                            },
                        }));
                    if (this.stopOnError)
                        this[RUNNING] = false; // 에러가 발생하면 정지
                    else {
                        request = true; // 데이터 불러옴
                        next(); // 다음 요청 시도
                    }
                }
            });
            // 요청 시도 이벤트
            super.dispatchEvent(new CustomEvent('request', {
                detail: {
                    target: { ...this.now },
                    params: {
                        ...this.loadParams,
                        ...options === null || options === void 0 ? void 0 : options.params,
                    },
                    ajax: options === null || options === void 0 ? void 0 : options.ajax,
                },
            }));
        };
        if (!this[RUNNING])
            load(); // 자동 요청이 실행중이 아니라면 시작
        return this; // 메소드 체이닝
    }
    /**
     * 계속 불러오기 중단
     * @returns 이 객체 (메소드 체이닝)
     */
    stop() {
        if (this[RUNNING]) {
            // 자동 요청이 실행중이면
            // 필수 대기시간
            if (this[TIMEOUT]) {
                clearTimeout(this[TIMEOUT]);
                this[TIMEOUT] = null;
            }
            else
                this[STOPPED] = true; // 요청중이면 결과 무시 예약
            // 쓰로틀 대기중이면
            if (this[THROTTLE]) {
                clearTimeout(this[THROTTLE]);
                this[THROTTLE] = null;
            }
            this[RUNNING] = false; // 정지로 표시
        }
        else
            throw new Error('자동 로드가 실행중이 아닙니다');
        return this; // 메소드 체이닝
    }
    /**
     * 현재(다음) 리비전 (지정된 리비전으로) 초기화
     * @param from 변경할 현재 리비전 위치
     * @returns 이 객체 (메소드 체이닝)
     */
    reset(from) {
        if (this[RUNNING])
            this.stop(); // 일단 진행중인 요청 정지
        this.now = from; // 리비전 위치 변경/초기화
        this.start(); // 다시 시작
        return this; // 메소드 체이닝
    }
    dispatchEvent() {
        throw new TypeError('이벤트를 임의로 발생시킬 수 없습니다');
    }
    /** 자동 요청 실행 여부 */
    get running() {
        return this[RUNNING];
    }
}
_a = RUNNING, _b = STOPPED, _c = THROTTLE, _d = TIMEOUT;
/** 기본 미디어위키 API 인터페이스 */
LiveClient.api = new mw.Api();
/** interval 기본값 */
LiveClient.interval = {
    throttle: null,
    margin: null,
};
/** loadParams 기본값 */
LiveClient.loadParams = {
    action: 'query',
    curtimestamp: true,
    prop: 'revisions',
    formatversion: 2,
    rvprop: ['ids', 'timestamp', 'user', 'content'],
    rvslots: 'main',
    rvlimit: 'max',
    rvdir: 'newer',
};
/** postParams 기본값 */
LiveClient.postParams = {
    action: 'edit',
    curtimestamp: true,
    minor: true,
};
/** loadAjax 기본값 */
LiveClient.loadAjax = {};
/** postAjax 기본값 */
LiveClient.postAjax = {};
/** 레이턴시 기록 및 보정 대기 시간 산출 클래스 */
class Latency {
    constructor() {
        /** 최근 레이턴시 기록 */
        this.history = [];
        /** 다른 사용자의 레이턴시 */
        this.peers = [];
    }
    /** 마지막 접속 레이턴시 */
    get last() {
        var _g;
        return (_g = this.history[0]) !== null && _g !== void 0 ? _g : null;
    }
    // 레이턴시 기록 추가시 오래된 기록 제거 (5개로 유지)
    set last(time) {
        if (typeof time !== 'number')
            throw new TypeError('last 속성에는 숫자만 할당할 수 있습니다');
        this.history.unshift(time);
        while (this.history.length > 5)
            this.history.pop();
    }
    /** 최근 평균 레이턴시 */
    get average() {
        if (!this.history.length)
            return null;
        return (this.history.reduce((sum, cur) => sum + cur) / this.history.length);
    }
    /** 자동 산출된 보정 대기 시간 */
    get delay() {
        if (!this.history.length)
            return null;
        return Math.max(
        // 평균값, 최댓값의 중간값
        (this.peers.reduce((sum, cur) => sum + cur, this.average) /
            (this.peers.length + 1) + // 평균
            Math.max(...this.peers)) /
            2 -
            this.average, // 자신의 평균 핑과의 차
        0 // 자연수가 아니면 0
        );
    }
}
/** 보정 대기 시간을 자동으로 적용하는 클라이언트 래퍼 */
export class Netcode {
    /**
     * Netcode 객체 생성자
     * @param client 넷코드를 연결할 클라이언트 및 클라이언트를 연결할 문서
     * @param config 넷코드 설정 객체
     */
    constructor(client, config = {}) {
        /** 다운로드 시작 시간 */
        this[_e] = [];
        if (!client)
            throw new TypeError('연결할 클라이언트나 문서를 지정하지 않았습니다');
        this.client =
            client instanceof LiveClient ? client : new LiveClient(client);
        this.running = false;
        this.upload = new Latency();
        this.download = new Latency();
        this.gateway = config.gateway || null;
        this.event =
            config.event || this.constructor.event;
        this[REQUEST] = this[REQUEST].bind(this);
        this[FETCH] = this[FETCH].bind(this);
        if (typeof config.report === 'function')
            this.report = config.report.bind(this);
    }
    /**
     * 자동 요청 시작
     * @param delay 최소 지연 시간
     * @param from 시작 리비전
     */
    start(delay, from) {
        this.client.start({ interval: delay, from });
        if (!this.gateway)
            this[TIMEOUT] = setTimeout(this.report, 30000);
        this.client.addEventListener('request', this[REQUEST]);
        this.client.addEventListener('fetch', this[FETCH]);
        this.running = true;
    }
    /**
     * 자동 레이턴시 보정 적용 해제
     * @param stopClient 클라이언트 자동 요청도 중단 (기본적으로 활성화됨)
     */
    stop(stopClient = true) {
        if (stopClient)
            this.client.stop();
        clearTimeout(this[TIMEOUT]);
        this.client.removeEventListener('request', this[REQUEST]);
        this.client.removeEventListener('fetch', this[FETCH]);
        this.running = false;
    }
    /**
     * 데이터 전송
     * @param content 내용
     */
    async post(content) {
        let before;
        const start = Date.now();
        if (typeof this.upload.last === 'number') {
            before = this.upload.average;
            await timeout(this.upload.delay);
        }
        const result = await this.client.post(content);
        this.upload.last =
            new Date(result.edit.newtimestamp).getTime() - start;
        if (!this.gateway &&
            typeof before === 'number' &&
            this.download.average !== null &&
            Math.abs(this.download.average - before) > 5000)
            this.report();
    }
    /** 레이턴시 정보 전송 */
    report() {
        var _g;
        return (_g = this.gateway) === null || _g === void 0 ? void 0 : _g.post(this.event, {
            up: this.upload.average,
            down: this.download.average,
        });
    }
    /**
     * 요청 시도시 소요시간 측정
     * @param event 이벤트 객체
     */
    [(_e = DOWN_START, REQUEST)](event) {
        this[DOWN_START].push(Date.now());
    }
    /**
     * 다운로드 완료시 소요시간 측정
     * @param event 이벤트 객체
     */
    [FETCH](event) {
        const before = this.download.average;
        if (this[DOWN_START].length)
            this.download.last = Date.now() - this[DOWN_START].shift();
        if (this.download.delay !== null)
            this.client.interval.throttle = this.download.delay;
        if (!this.gateway &&
            before !== null &&
            this.download.average !== null &&
            Math.abs(this.download.average - before) > 5000)
            this.report();
    }
}
/** 레이턴시 공유 이벤트 종류 이름 기본값 */
Netcode.event = 'connect';
/**
 * Promise를 사용하는 일시 중단 함수
 * @param sec 일시 중단 시간 (ms)
 * @returns Promise
 */
function timeout(sec) {
    return new Promise((resolve) => setTimeout(resolve, sec));
}
/**
 * 서버 이벤트를 자동으로 실행
 *
 * LiveEventDistributor, LiveEventGateway의 기반 클래스. 직접 사용하지 않는 상속용 클래스
 *
 * EventTarget과 유사한 구조를 가지고 있지만, EventTarget을 상속받지 않고 Event 객체를 받지 않는 클래스이므로 주의하세요.
 */
class LiveEventTarget {
    /**
     * LiveEventTarget 객체 생성자
     * @param events 등록할 액션
     * @param config 추가 설정
     */
    constructor(events = {}, config = {}) {
        /** 등록된 이벤트 */
        this.listeners = {};
        this.onceListeners = {};
        this.handle = (config.handle || this.handle).bind(this);
        if (events)
            this.addEventListener(events);
    }
    addEventListener(typeOrListeners, callbackOrOptions, options) {
        switch (typeof typeOrListeners) {
            case 'object': // 객체를 통해 여러 타입의 리스너를 추가할 경우
                for (const type in typeOrListeners)
                    this.addEventListener(type, typeOrListeners[type], callbackOrOptions);
                break;
            case 'string': // 하나의 리스너를 추가할 경우
                switch (typeof callbackOrOptions) {
                    case 'object': // 두번째 인자가 객체일 때 handleEvent가 있는지 확인하여 리스너 객체인지 확인
                        if (!('handleEvent' in callbackOrOptions))
                            // 리스너 객체가 아니면 타입 오류
                            throw new TypeError(`${typeOrListeners} 타입에 대한 이벤트 리스너가 올바르지 않습니다`);
                    // fallthrough // 리스너 객체면 속행
                    case 'function':
                        if (options === null || options === void 0 ? void 0 : options.once) {
                            if (!this.listeners[typeOrListeners])
                                this.listeners[typeOrListeners] = new Set();
                        }
                        else {
                            if (!this.onceListeners[typeOrListeners])
                                this.onceListeners[typeOrListeners] = new Set();
                        }
                        ((options === null || options === void 0 ? void 0 : options.once)
                            ? this.onceListeners[typeOrListeners]
                            : this.listeners[typeOrListeners]).add(callbackOrOptions);
                        break;
                    default:
                        throw new TypeError(`${typeOrListeners} 타입에 대한 이벤트 리스너가 올바르지 않습니다`);
                }
                break;
            default:
                throw new TypeError('addEventListener 메소드에 올바른 인자를 넣지 않았습니다');
        }
        return this;
    }
    removeEventListener(type, callback, options) {
        var _g;
        if (typeof type === 'string') {
            // 첫번째 인자에 하나의 type만 넣을 경우
            if (options && 'once' in options) {
                // option.once 지정시 onceListeners 또는 listeners 둘 중 하나 제거
                const listeners = (options === null || options === void 0 ? void 0 : options.once)
                    ? this.listeners
                    : this.onceListeners;
                if (callback)
                    // callback 지정시 지정된 callback만 제거
                    (_g = listeners[type]) === null || _g === void 0 ? void 0 : _g.delete(callback);
                else
                    delete listeners[type]; // 미지정시 해당 type의 모든 callback 제거
            }
            else {
                // option.once 미지정시 onceListeners 또는 listeners 둘 다 제거
                this.removeEventListener(type, callback, { once: false });
                this.removeEventListener(type, callback, { once: true });
            }
        }
        else if (type.forEach)
            // 첫번째 인자에 배열이나 Set을 통해 여러 type을 보낼 경우 모든 type 제거
            type.forEach((type) => this.removeEventListener(type, null, callback));
        else
            throw new TypeError('removeEventListener 메소드에 올바른 인자를 넣지 않았습니다');
        return this;
    }
    dispatchEvent(type, arg, rev) {
        var _g, _h;
        const run = (listener) => {
            try {
                switch (typeof listener) {
                    case 'function':
                        listener(arg, rev);
                        break;
                    case 'object':
                        listener.handleEvent(arg, rev);
                        break;
                }
            }
            catch (error) {
                console.error('Uncaught', error);
                window.dispatchEvent(new ErrorEvent('error', error));
            }
        };
        (_g = this.listeners[type]) === null || _g === void 0 ? void 0 : _g.forEach(run);
        (_h = this.onceListeners[type]) === null || _h === void 0 ? void 0 : _h.forEach(run);
        delete this.onceListeners[type];
    }
    /**
     * 폴링 데이터를 받을 때 실행되어 이벤트를 실행하는 클라이언트 이벤트 핸들러
     * @param event 이벤트 객체
     */
    handle({ detail }) {
        console.log(...arguments);
        for (const rev of detail.revisions) {
            const content = rev.slots.main.content;
            const sep = content.search(' ');
            const type = content.slice(0, sep);
            const arg = parseJSON(content.slice(sep + 1));
            this.dispatchEvent(type, arg, rev);
        }
    }
}
/**
 * 서버 이벤트를 자동으로 실행
 *
 * 여러 클라이언트를 연결해 서버 이벤트를 분배할 수 있지만, 이벤트를 서버에 전송할 수 없습니다. LiveEventGateway를 이용해 보세요.
 *
 * EventTarget과 유사한 구조를 가지고 있지만, EventTarget을 상속받지 않고 Event 객체를 받지 않는 클래스이므로 주의하세요.
 */
export class LiveEventDistributor extends LiveEventTarget {
    /**
     * LiveEventDistributor 객체 생성자
     * @param clients 연결할 클라이언트
     * @param events 등록할 액션
     * @param config 추가 설정
     */
    constructor(clients, events = {}, config = {}) {
        super(events, config);
        /** 연결된 클라이언트 */
        this[_f] = new Set();
        if (clients)
            this.connect(clients);
    }
    /** 클라이언트 목록 불러오기 */
    get clients() {
        return [...this[CLIENTS]];
    }
    /**
     * 클라이언트 연결
     * @param clients 연결할 클라이언트
     * @returns 이 객체 (메소드 체이닝)
     */
    connect(...clients) {
        if (Array.isArray(clients[0]) || clients[0] instanceof Set)
            clients = clients[0];
        for (const client of clients) {
            if (!(client instanceof LiveClient))
                throw new TypeError('EventDistributor에 LiveClient가 아닌 객체를 연결하려 시도했습니다');
            if (this[CLIENTS].has(client))
                continue;
            this[CLIENTS].add(client);
            client.addEventListener('update', 
            this.handle);
        }
        return this;
    }
    /**
     * 클라이언트 연결 해제
     * @param clients 연결 해제할 클라이언트
     * @returns 이 객체 (메소드 체이닝)
     */
    disconnect(...clients) {
        if (Array.isArray(clients[0]) || clients[0] instanceof Set)
            clients = clients[0];
        if (clients.length <= 0)
            clients = this.clients;
        for (const client of clients) {
            if (!(client instanceof LiveClient))
                throw new TypeError('disconnect 메소드에 잘못된 인자를 넣었습니다');
            if (!this[CLIENTS].has(client))
                continue;
            client.removeEventListener('update', 
            this.handle);
            this[CLIENTS].delete(client);
        }
        return this;
    }
}
_f = CLIENTS;
/**
 * 서버 이벤트를 자동으로 실행
 *
 * 하나의 클라이언트를 연결해 이벤트를 분배받고, 서버에 이벤트를 전송할 수 있습니다. 여러 클라이언트를 연결하려면 LiveEventDistributor를 이용해 보세요.
 *
 * EventTarget과 유사한 구조를 가지고 있지만, EventTarget을 상속받지 않고 Event 객체를 받지 않는 클래스이므로 주의하세요.
 */
export class LiveEventGateway extends LiveEventTarget {
    /**
     * LiveEventGateway 객체 생성자
     * @param client 연결할 클라이언트
     * @param events 등록할 액션
     * @param config 추가 설정
     */
    constructor(client, events = {}, config = {}) {
        if (!(client instanceof LiveClient))
            throw new TypeError('EventGateway에 LiveClient가 아닌 객체를 연결하려 시도했습니다');
        super(events, config);
        this.client = client;
        client.addEventListener('update', 
        this.handle);
    }
    /**
     * 이벤트 전송
     * @param type 이벤트 타입 이름
     * @param content 이벤트 인자값
     * @returns 업로드 결과
     */
    post(type, content) {
        return this.client.post(`${type} ${JSON.stringify(content)}`);
    }
}