본문으로 이동

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

리버티게임, 모두가 만들어가는 자유로운 게임
imported>Hsl0님의 2020년 9월 23일 (수) 01:07 판

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

  • 파이어폭스 / 사파리: Shift 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5 또는 Ctrl-R을 입력 (Mac에서는 ⌘-R)
  • 구글 크롬: Ctrl-Shift-R키를 입력 (Mac에서는 ⌘-Shift-R)
  • 엣지: Ctrl 키를 누르면서 새로 고침을 클릭하거나, Ctrl-F5를 입력.
const api = new mw.Api();

const TIMEOUT = Symbol('timeout_id');
const STOPPED = Symbol('is_stopped');
const RUNNING = Symbol('is_running');
const THROTTLE = Symbol('throttle_timeout_id');
const START = Symbol('process_start_time');
const REQUEST = Symbol('request_event_handler');
const FETCH = Symbol('fetch_event_handler');

export class LiveClient extends EventTarget {
	constructor(title, options = {}) {
		if(!title) throw new TypeError('문서를 지정해 주세요');
		
		super();
		this.src = title;
		this.interval = options.interval || {
			throttle: null,
			margin: null
		};
		this.loadParams = options.loadParams || {
			action: "query",
			curtimestamp: true,
			prop: "revisions",
			titles: this.src,
			formatversion: 2,
			rvprop: ['ids', 'timestamp', 'user', 'content'],
			rvlimit: "max",
			rvdir: "newer",
		};
		this.postParams = options.postParams || {
			action: "edit",
			curtimestamp: true,
			title: this.src,
			minor: true
		};
		this.now = options.from;
		this.stopError = 'stopError' in options? options.stopError : true; 
		this[RUNNING] = false;
	}
	// 1회 불러오기
	load(from, options = {}) {
		const params = {};
		
		// 시작 리비전
		switch(typeof from) {
			case 'string':
				params.rvcontinue = from;
				break;
			case 'object':
				if(from.timestamp) {
					if(from.id) params.rvcontinue = from.timestamp + '|' + from.id;
					else params.rvfrom = from.timestamp;
				} else if(from.id) params.rvfromid = from.id;
				break;
		}
		
		return api.get(Object.assign(params, this.loadParams, options.params), options.ajax).then(response => {
			if(response.error) {
				// API 에러
				throw {
					type: 'api',
					error: response.error,
					target: from,
					response
				};
			} else {
				const revisions = response.query && response.query.pages[0].revisions;
				const result = {
					response,
					revisions,
					target: from
				};
				
				// 갱신됨 (후순위 동작)
				if(!options.silent && revisions) setTimeout(() => super.dispatchEvent(new CustomEvent('update', {
					detail: result
				})), 0);
				
				return result;
			}
		}, error => {
			// HTTP 에러
			throw {
				type: 'fetch',
				error,
				target: from,
				response: null
			};
		});
	}
	// 데이터 전송
	post(content) {
		return api.postWithEditToken(Object.assign({
			text: content
		}, this.postParams));
	}
	// 계속 불러오기 (간격 재설정)
	start(interval) {
		Object.assign(this.interval, interval); // 시간 간격 갱신
		
		if(typeof this.interval !== 'object' && (typeof this.interval.throttle !== 'number' || typeof this.interval.margin !== 'number')) {
			throw new TypeError('정상적인 간격이 지정되지 않았습니다');
		}
		
		const load = options => {
			// 다음 폴링
			const next = () => {
				if(throttle && request) this[TIMEOUT] = setTimeout(load, this.interval.margin, {
					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);
			
			// AJAX 요청
			this.load(this.now, options).then(result => {
				if(this[STOPPED]) this[STOPPED] = false; // 정지가 예약되면 무시하고 다음 예약 해제
				else {
					const response = result.response;
					const revisions = result.revisions;
					
					result.error = false;
					result.updated = Boolean(revisions);
					delete result.revisions;
					// 로드 완료
					super.dispatchEvent(new CustomEvent('fetch', {
						detail: result
					}));
					
					if(revisions) {
						// 이번 요청에서 데이터가 갱신되었을 때
						if(response.continue) {
							// 더 불러올 데이터가 있으면 바로 요청
							this.now = response.continue.rvcontinue;
							
							return load();
						} else {
							// 더 불러올 데이터가 없으면
							const last = revisions[revisions.length - 1];
							this.now = {
								id: last.revid,
								timestamp: last.timestamp
							};
							this.now.id++;
						}
					}
					
					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.stopError) this[RUNNING] = false; // 에러가 발생하면 정지
					else {
						request = true; // 데이터 불러옴
						next(); // 다음 요청 시도
					}
				}
			});
			
			// 요청 시도 이벤트
			super.dispatchEvent(new CustomEvent('request', {
				detail: {
					target: typeof this.now === 'object'? Object.assign({}, this.now) : this.now,
					params: Object.assign({}, this.loadParams, options && options.params),
					ajax: options && options.ajax
				}
			}));
		};
		if(!this[RUNNING]) load(); // 자동 요청이 실행중이 아니라면 시작
		
		return this; // 메소드 체이닝
	}
	stop(noStrict) {
		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 if(!noStrict) throw new Error('자동 로드가 실행중이 아닙니다');
		
		return this; // 메소드 체이닝
	}
	// 현재(다음) 리비전 (지정된 리비전으로) 초기화
	reset(from) {
		if(this[RUNNING]) stop(); // 일단 진행중인 요청 정지
		
		this.now = from; // 리비전 위치 변경/초기화
		start(); // 다시 시작
		
		return this; // 메소드 체이닝
	}
	dispatchEvent() {
		throw new TypeError('이벤트를 임의로 발생시킬 수 없습니다');
	}
	// 자동 요청 실행 여부
	get running() {
		return this[RUNNING];
	}
}

class Latency {
	constructor(config = {}) {
		this.history = [];
		this.peers = [];
		this[START] = null;
	}
	
	get last() {
		return this.history[0];
	}
	set last(time) {
		const before = this.average;
		
		this.history.unshift(time);
		if(this.history.length > 5) this.history.pop();
		
		if(!this.noReport && Math.abs(before - this.average) > 5000) report();
	}
	
	get average() {
		return this.history.reduce((sum, cur) => sum + cur) / this.history.length;
	}
	
	get delay() {
		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 {
	constructor(client, config = {}) {
		if(!client) throw new TypeError('연결할 클라이언트나 문서를 지정하지 않았습니다');
		
		this.client = client instanceof LiveClient? client : new LiveClient(client);
		this.running = false;
		this.upload = new Latency(config.upload);
		this.download = new Latency(config.download);
		this.noReport = 'noReport' in config? config.noReport : typeof config.report !== 'function';
		this[TIMEOUT] = null;
		this[REQUEST] = this[REQUEST].bind(this);
		this[FETCH] = this[FETCH].bind(this);
		
		if(typeof config.report === 'function') this.report = config.report;
	}
	
	start(delay) {
		this.client.start(delay);
		
		this[TIMEOUT] = setTimeout(report, 30000);
		
		this.client.addEventListener('request', this[REQUEST]);
		this.client.addEventListener('fetch', this[FETCH]);
		
		this.running = true;
	}
	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;
	}
	async post(content) {
		let before;
		
		this.upload[START] = Date.now();
		
		if(this.upload.length) {
			before = this.upload.average;
			await timeout(this.upload.delay);
		}
		await this.client.post(content);
		
		this.upload.last = Date.now() - this.upload[START];
		if(!this.noReport && typeof before === 'number' && Math.abs(this.download.average - before) > 5000) this.report();
	}
	
	get report() {
		return () => {
			throw new TypeError('추상 메소드 report가 정의되지 않은 채로 실행되었습니다');
		};
	}
	set report(func) {
		delete this.report;
		this.report = (...args) => {
			clearTimeout(this[TIMEOUT]);
			this[TIMEOUT] = setTimeout(this.report, 30000);
			
			return func(...args);
		};
	}
	
	[REQUEST](event) {
		this.download[START] = Date.now();
	}
	[FETCH](event) {
		const before = this.download.average;
		
		this.download.last = Date.now() - this.download[START];
		this.client.timeout.throttle = this.download.delay;
		
		if(!this.noReport && Math.abs(this.download.average - before) > 5000) this.report();
	}
}
function timeout(sec) {
	return new Promise(resolve => setTimeout(resolve, sec));
}