mirror of
https://github.com/nullnyat/NullcatChan.git
synced 2025-04-29 03:07:19 +09:00
色々消し消し
This commit is contained in:
parent
44e25bb499
commit
eb853be7e8
Binary file not shown.
Before Width: | Height: | Size: 30 KiB |
14
ai.svg
14
ai.svg
@ -1,14 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
|
||||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
|
||||||
<svg width="100%" height="100%" viewBox="0 0 512 512" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
|
||||||
<g id="レイヤー-5" serif:id="レイヤー 5">
|
|
||||||
<path id="path4542" d="M125.972,500.997C122.6,507.74 115.708,512 108.169,512C85.271,512 36.32,512 12.944,512C10.172,512 7.597,510.564 6.139,508.206C4.681,505.847 4.549,502.902 5.789,500.422C32.536,446.929 144.098,223.803 173.55,164.899C174.906,162.189 177.676,160.477 180.706,160.477C183.736,160.477 186.506,162.189 187.861,164.899C217.313,223.803 328.876,446.929 355.623,500.422C356.863,502.902 356.73,505.847 355.273,508.206C353.815,510.564 351.24,512 348.468,512C325.092,512 276.141,512 253.243,512C245.704,512 238.811,507.74 235.44,500.997C224.508,479.134 200.061,430.239 187.884,405.885C186.524,403.166 183.745,401.449 180.706,401.449C177.666,401.449 174.888,403.166 173.528,405.885C161.351,430.239 136.904,479.134 125.972,500.997Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/>
|
|
||||||
<path id="path4544" d="M263.155,14.311C261.8,11.601 259.03,9.889 256,9.889C252.97,9.889 250.2,11.601 248.845,14.311C236.998,38.005 213.494,85.013 202.165,107.671C198.136,115.728 198.136,125.213 202.165,133.27C213.494,155.928 236.998,202.936 248.845,226.63C250.2,229.341 252.97,231.053 256,231.053C259.03,231.053 261.8,229.341 263.155,226.63C275.002,202.936 298.506,155.928 309.835,133.27C313.864,125.213 313.864,115.728 309.835,107.671C298.506,85.013 275.002,38.005 263.155,14.311Z" style="fill:url(#_Linear2);fill-rule:nonzero;"/>
|
|
||||||
<path id="path4546" d="M506.211,500.422C507.451,502.902 507.319,505.847 505.861,508.206C504.403,510.564 501.828,512 499.056,512C476.392,512 429.685,512 405.993,512C397.129,512 389.025,506.992 385.061,499.064C364.356,457.653 299.8,328.542 278.189,285.32C273.701,276.342 273.701,265.775 278.189,256.798C289.771,233.634 312.541,188.095 324.139,164.899C325.494,162.189 328.264,160.477 331.294,160.477C334.324,160.477 337.094,162.189 338.45,164.899C367.902,223.803 479.465,446.929 506.211,500.422Z" style="fill:url(#_Linear3);fill-rule:nonzero;"/>
|
|
||||||
</g>
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(361.412,0,0,361.412,1.88976e-05,331.294)"><stop offset="0" style="stop-color:rgb(71,116,158);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(192,139,174);stop-opacity:1"/></linearGradient>
|
|
||||||
<linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(316.235,0,0,512,195.765,256)"><stop offset="0" style="stop-color:rgb(71,116,158);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(202,217,201);stop-opacity:1"/></linearGradient>
|
|
||||||
<linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(316.235,0,0,512,195.765,256)"><stop offset="0" style="stop-color:rgb(71,116,158);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(202,217,201);stop-opacity:1"/></linearGradient>
|
|
||||||
</defs>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 3.3 KiB |
489
src/ai.ts
489
src/ai.ts
@ -1,489 +0,0 @@
|
|||||||
// AI CORE
|
|
||||||
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import autobind from 'autobind-decorator';
|
|
||||||
import * as loki from 'lokijs';
|
|
||||||
import * as request from 'request-promise-native';
|
|
||||||
import * as chalk from 'chalk';
|
|
||||||
import { v4 as uuid } from 'uuid';
|
|
||||||
const delay = require('timeout-as-promise');
|
|
||||||
|
|
||||||
import config from '@/config';
|
|
||||||
import Module from '@/module';
|
|
||||||
import Message from '@/message';
|
|
||||||
import Friend, { FriendDoc } from '@/friend';
|
|
||||||
import { User } from '@/misskey/user';
|
|
||||||
import Stream from '@/stream';
|
|
||||||
import log from '@/utils/log';
|
|
||||||
const pkg = require('../package.json');
|
|
||||||
|
|
||||||
type MentionHook = (msg: Message) => Promise<boolean | HandlerResult>;
|
|
||||||
type ContextHook = (key: any, msg: Message, data?: any) => Promise<void | boolean | HandlerResult>;
|
|
||||||
type TimeoutCallback = (data?: any) => void;
|
|
||||||
|
|
||||||
export type HandlerResult = {
|
|
||||||
reaction?: string | null;
|
|
||||||
immediate?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type InstallerResult = {
|
|
||||||
mentionHook?: MentionHook;
|
|
||||||
contextHook?: ContextHook;
|
|
||||||
timeoutCallback?: TimeoutCallback;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Meta = {
|
|
||||||
lastWakingAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 藍
|
|
||||||
*/
|
|
||||||
export default class 藍 {
|
|
||||||
public readonly version = pkg._v;
|
|
||||||
public account: User;
|
|
||||||
public connection: Stream;
|
|
||||||
public modules: Module[] = [];
|
|
||||||
private mentionHooks: MentionHook[] = [];
|
|
||||||
private contextHooks: { [moduleName: string]: ContextHook } = {};
|
|
||||||
private timeoutCallbacks: { [moduleName: string]: TimeoutCallback } = {};
|
|
||||||
public db: loki;
|
|
||||||
public lastSleepedAt: number;
|
|
||||||
|
|
||||||
private meta: loki.Collection<Meta>;
|
|
||||||
|
|
||||||
private contexts: loki.Collection<{
|
|
||||||
isDm: boolean;
|
|
||||||
noteId?: string;
|
|
||||||
userId?: string;
|
|
||||||
module: string;
|
|
||||||
key: string | null;
|
|
||||||
data?: any;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
private timers: loki.Collection<{
|
|
||||||
id: string;
|
|
||||||
module: string;
|
|
||||||
insertedAt: number;
|
|
||||||
delay: number;
|
|
||||||
data?: any;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
public friends: loki.Collection<FriendDoc>;
|
|
||||||
public moduleData: loki.Collection<any>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 藍インスタンスを生成します
|
|
||||||
* @param account 藍として使うアカウント
|
|
||||||
* @param modules モジュール。先頭のモジュールほど高優先度
|
|
||||||
*/
|
|
||||||
constructor(account: User, modules: Module[]) {
|
|
||||||
this.account = account;
|
|
||||||
this.modules = modules;
|
|
||||||
|
|
||||||
let memoryDir = '.';
|
|
||||||
if (config.memoryDir) {
|
|
||||||
memoryDir = config.memoryDir;
|
|
||||||
}
|
|
||||||
const file = process.env.NODE_ENV === 'test' ? `${memoryDir}/test.memory.json` : `${memoryDir}/memory.json`;
|
|
||||||
|
|
||||||
this.log(`Lodaing the memory from ${file}...`);
|
|
||||||
|
|
||||||
this.db = new loki(file, {
|
|
||||||
autoload: true,
|
|
||||||
autosave: true,
|
|
||||||
autosaveInterval: 1000,
|
|
||||||
autoloadCallback: err => {
|
|
||||||
if (err) {
|
|
||||||
this.log(chalk.red(`Failed to load the memory: ${err}`));
|
|
||||||
} else {
|
|
||||||
this.log(chalk.green('The memory loaded successfully'));
|
|
||||||
this.run();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public log(msg: string) {
|
|
||||||
log(chalk`[{magenta AiOS}]: ${msg}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private run() {
|
|
||||||
//#region Init DB
|
|
||||||
this.meta = this.getCollection('meta', {});
|
|
||||||
|
|
||||||
this.contexts = this.getCollection('contexts', {
|
|
||||||
indices: ['key']
|
|
||||||
});
|
|
||||||
|
|
||||||
this.timers = this.getCollection('timers', {
|
|
||||||
indices: ['module']
|
|
||||||
});
|
|
||||||
|
|
||||||
this.friends = this.getCollection('friends', {
|
|
||||||
indices: ['userId']
|
|
||||||
});
|
|
||||||
|
|
||||||
this.moduleData = this.getCollection('moduleData', {
|
|
||||||
indices: ['module']
|
|
||||||
});
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
const meta = this.getMeta();
|
|
||||||
this.lastSleepedAt = meta.lastWakingAt;
|
|
||||||
|
|
||||||
// Init stream
|
|
||||||
this.connection = new Stream();
|
|
||||||
|
|
||||||
//#region Main stream
|
|
||||||
const mainStream = this.connection.useSharedConnection('main');
|
|
||||||
|
|
||||||
// メンションされたとき
|
|
||||||
mainStream.on('mention', async data => {
|
|
||||||
if (data.userId == this.account.id) return; // 自分は弾く
|
|
||||||
if (data.text && data.text.startsWith('@' + this.account.username)) {
|
|
||||||
// Misskeyのバグで投稿が非公開扱いになる
|
|
||||||
if (data.text == null) data = await this.api('notes/show', { noteId: data.id });
|
|
||||||
this.onReceiveMessage(new Message(this, data, false));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 返信されたとき
|
|
||||||
mainStream.on('reply', async data => {
|
|
||||||
if (data.userId == this.account.id) return; // 自分は弾く
|
|
||||||
if (data.text && data.text.startsWith('@' + this.account.username)) return;
|
|
||||||
// Misskeyのバグで投稿が非公開扱いになる
|
|
||||||
if (data.text == null) data = await this.api('notes/show', { noteId: data.id });
|
|
||||||
this.onReceiveMessage(new Message(this, data, false));
|
|
||||||
});
|
|
||||||
|
|
||||||
// Renoteされたとき
|
|
||||||
mainStream.on('renote', async data => {
|
|
||||||
if (data.userId == this.account.id) return; // 自分は弾く
|
|
||||||
if (data.text == null && (data.files || []).length == 0) return;
|
|
||||||
|
|
||||||
// リアクションする
|
|
||||||
this.api('notes/reactions/create', {
|
|
||||||
noteId: data.id,
|
|
||||||
reaction: 'love'
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// メッセージ
|
|
||||||
mainStream.on('messagingMessage', data => {
|
|
||||||
if (data.userId == this.account.id) return; // 自分は弾く
|
|
||||||
this.onReceiveMessage(new Message(this, data, true));
|
|
||||||
});
|
|
||||||
|
|
||||||
// 通知
|
|
||||||
mainStream.on('notification', data => {
|
|
||||||
this.onNotification(data);
|
|
||||||
});
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
// Install modules
|
|
||||||
this.modules.forEach(m => {
|
|
||||||
this.log(`Installing ${chalk.cyan.italic(m.name)}\tmodule...`);
|
|
||||||
m.init(this);
|
|
||||||
const res = m.install();
|
|
||||||
if (res != null) {
|
|
||||||
if (res.mentionHook) this.mentionHooks.push(res.mentionHook);
|
|
||||||
if (res.contextHook) this.contextHooks[m.name] = res.contextHook;
|
|
||||||
if (res.timeoutCallback) this.timeoutCallbacks[m.name] = res.timeoutCallback;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// タイマー監視
|
|
||||||
this.crawleTimer();
|
|
||||||
setInterval(this.crawleTimer, 1000);
|
|
||||||
|
|
||||||
setInterval(this.logWaking, 10000);
|
|
||||||
|
|
||||||
this.log(chalk.green.bold('Ai am now running!'));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ユーザーから話しかけられたとき
|
|
||||||
* (メンション、リプライ、トークのメッセージ)
|
|
||||||
*/
|
|
||||||
@autobind
|
|
||||||
private async onReceiveMessage(msg: Message): Promise<void> {
|
|
||||||
this.log(chalk.gray(`<<< An message received: ${chalk.underline(msg.id)}`));
|
|
||||||
|
|
||||||
// Ignore message if the user is a bot
|
|
||||||
// To avoid infinity reply loop.
|
|
||||||
if (msg.user.isBot) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isNoContext = !msg.isDm && msg.replyId == null;
|
|
||||||
|
|
||||||
// Look up the context
|
|
||||||
const context = isNoContext ? null : this.contexts.findOne(msg.isDm ? {
|
|
||||||
isDm: true,
|
|
||||||
userId: msg.userId
|
|
||||||
} : {
|
|
||||||
isDm: false,
|
|
||||||
noteId: msg.replyId
|
|
||||||
});
|
|
||||||
|
|
||||||
let reaction: string | null = 'love';
|
|
||||||
let immediate: boolean = false;
|
|
||||||
|
|
||||||
//#region
|
|
||||||
const invokeMentionHooks = async () => {
|
|
||||||
let res: boolean | HandlerResult | null = null;
|
|
||||||
|
|
||||||
for (const handler of this.mentionHooks) {
|
|
||||||
res = await handler(msg);
|
|
||||||
if (res === true || typeof res === 'object') break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res != null && typeof res === 'object') {
|
|
||||||
if (res.reaction != null) reaction = res.reaction;
|
|
||||||
if (res.immediate != null) immediate = res.immediate;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// コンテキストがあればコンテキストフック呼び出し
|
|
||||||
// なければそれぞれのモジュールについてフックが引っかかるまで呼び出し
|
|
||||||
if (context != null) {
|
|
||||||
const handler = this.contextHooks[context.module];
|
|
||||||
const res = await handler(context.key, msg, context.data);
|
|
||||||
|
|
||||||
if (res != null && typeof res === 'object') {
|
|
||||||
if (res.reaction != null) reaction = res.reaction;
|
|
||||||
if (res.immediate != null) immediate = res.immediate;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (res === false) {
|
|
||||||
await invokeMentionHooks();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await invokeMentionHooks();
|
|
||||||
}
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
if (!immediate) {
|
|
||||||
await delay(1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.isDm) {
|
|
||||||
// 既読にする
|
|
||||||
this.api('messaging/messages/read', {
|
|
||||||
messageId: msg.id,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// リアクションする
|
|
||||||
if (reaction) {
|
|
||||||
this.api('notes/reactions/create', {
|
|
||||||
noteId: msg.id,
|
|
||||||
reaction: reaction
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private onNotification(notification: any) {
|
|
||||||
switch (notification.type) {
|
|
||||||
// リアクションされたら親愛度を少し上げる
|
|
||||||
// TODO: リアクション取り消しをよしなにハンドリングする
|
|
||||||
case 'reaction': {
|
|
||||||
const friend = new Friend(this, { user: notification.user });
|
|
||||||
friend.incLove(0.1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private crawleTimer() {
|
|
||||||
const timers = this.timers.find();
|
|
||||||
for (const timer of timers) {
|
|
||||||
// タイマーが時間切れかどうか
|
|
||||||
if (Date.now() - (timer.insertedAt + timer.delay) >= 0) {
|
|
||||||
this.log(`Timer expired: ${timer.module} ${timer.id}`);
|
|
||||||
this.timers.remove(timer);
|
|
||||||
this.timeoutCallbacks[timer.module](timer.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private logWaking() {
|
|
||||||
this.setMeta({
|
|
||||||
lastWakingAt: Date.now(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* データベースのコレクションを取得します
|
|
||||||
*/
|
|
||||||
@autobind
|
|
||||||
public getCollection(name: string, opts?: any): loki.Collection {
|
|
||||||
let collection: loki.Collection;
|
|
||||||
|
|
||||||
collection = this.db.getCollection(name);
|
|
||||||
|
|
||||||
if (collection == null) {
|
|
||||||
collection = this.db.addCollection(name, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
return collection;
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public lookupFriend(userId: User['id']): Friend | null {
|
|
||||||
const doc = this.friends.findOne({
|
|
||||||
userId: userId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (doc == null) return null;
|
|
||||||
|
|
||||||
const friend = new Friend(this, { doc: doc });
|
|
||||||
|
|
||||||
return friend;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ファイルをドライブにアップロードします
|
|
||||||
*/
|
|
||||||
@autobind
|
|
||||||
public async upload(file: Buffer | fs.ReadStream, meta: any) {
|
|
||||||
const res = await request.post({
|
|
||||||
url: `${config.apiUrl}/drive/files/create`,
|
|
||||||
formData: {
|
|
||||||
i: config.i,
|
|
||||||
file: {
|
|
||||||
value: file,
|
|
||||||
options: meta
|
|
||||||
}
|
|
||||||
},
|
|
||||||
json: true
|
|
||||||
});
|
|
||||||
return res;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 投稿します
|
|
||||||
*/
|
|
||||||
@autobind
|
|
||||||
public async post(param: any) {
|
|
||||||
const res = await this.api('notes/create', param);
|
|
||||||
return res.createdNote;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 指定ユーザーにトークメッセージを送信します
|
|
||||||
*/
|
|
||||||
@autobind
|
|
||||||
public sendMessage(userId: any, param: any) {
|
|
||||||
return this.api('messaging/messages/create', Object.assign({
|
|
||||||
userId: userId,
|
|
||||||
}, param));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* APIを呼び出します
|
|
||||||
*/
|
|
||||||
@autobind
|
|
||||||
public api(endpoint: string, param?: any) {
|
|
||||||
return request.post(`${config.apiUrl}/${endpoint}`, {
|
|
||||||
json: Object.assign({
|
|
||||||
i: config.i
|
|
||||||
}, param)
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* コンテキストを生成し、ユーザーからの返信を待ち受けます
|
|
||||||
* @param module 待ち受けるモジュール名
|
|
||||||
* @param key コンテキストを識別するためのキー
|
|
||||||
* @param isDm トークメッセージ上のコンテキストかどうか
|
|
||||||
* @param id トークメッセージ上のコンテキストならばトーク相手のID、そうでないなら待ち受ける投稿のID
|
|
||||||
* @param data コンテキストに保存するオプションのデータ
|
|
||||||
*/
|
|
||||||
@autobind
|
|
||||||
public subscribeReply(module: Module, key: string | null, isDm: boolean, id: string, data?: any) {
|
|
||||||
this.contexts.insertOne(isDm ? {
|
|
||||||
isDm: true,
|
|
||||||
userId: id,
|
|
||||||
module: module.name,
|
|
||||||
key: key,
|
|
||||||
data: data
|
|
||||||
} : {
|
|
||||||
isDm: false,
|
|
||||||
noteId: id,
|
|
||||||
module: module.name,
|
|
||||||
key: key,
|
|
||||||
data: data
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 返信の待ち受けを解除します
|
|
||||||
* @param module 解除するモジュール名
|
|
||||||
* @param key コンテキストを識別するためのキー
|
|
||||||
*/
|
|
||||||
@autobind
|
|
||||||
public unsubscribeReply(module: Module, key: string | null) {
|
|
||||||
this.contexts.findAndRemove({
|
|
||||||
key: key,
|
|
||||||
module: module.name
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 指定したミリ秒経過後に、そのモジュールのタイムアウトコールバックを呼び出します。
|
|
||||||
* このタイマーは記憶に永続化されるので、途中でプロセスを再起動しても有効です。
|
|
||||||
* @param module モジュール名
|
|
||||||
* @param delay ミリ秒
|
|
||||||
* @param data オプションのデータ
|
|
||||||
*/
|
|
||||||
@autobind
|
|
||||||
public setTimeoutWithPersistence(module: Module, delay: number, data?: any) {
|
|
||||||
const id = uuid();
|
|
||||||
this.timers.insertOne({
|
|
||||||
id: id,
|
|
||||||
module: module.name,
|
|
||||||
insertedAt: Date.now(),
|
|
||||||
delay: delay,
|
|
||||||
data: data
|
|
||||||
});
|
|
||||||
|
|
||||||
this.log(`Timer persisted: ${module.name} ${id} ${delay}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public getMeta() {
|
|
||||||
const rec = this.meta.findOne();
|
|
||||||
|
|
||||||
if (rec) {
|
|
||||||
return rec;
|
|
||||||
} else {
|
|
||||||
const initial: Meta = {
|
|
||||||
lastWakingAt: Date.now(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.meta.insertOne(initial);
|
|
||||||
return initial;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public setMeta(meta: Partial<Meta>) {
|
|
||||||
const rec = this.getMeta();
|
|
||||||
|
|
||||||
for (const [k, v] of Object.entries(meta)) {
|
|
||||||
rec[k] = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.meta.update(rec);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,160 +0,0 @@
|
|||||||
import autobind from 'autobind-decorator';
|
|
||||||
import Module from '@/module';
|
|
||||||
import serifs from '@/serifs';
|
|
||||||
import Message from '@/message';
|
|
||||||
import { renderChart } from './render-chart';
|
|
||||||
import { items } from '@/vocabulary';
|
|
||||||
import config from '@/config';
|
|
||||||
|
|
||||||
export default class extends Module {
|
|
||||||
public readonly name = 'chart';
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public install() {
|
|
||||||
if (config.chartEnabled === false) return {};
|
|
||||||
|
|
||||||
this.post();
|
|
||||||
setInterval(this.post, 1000 * 60 * 3);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mentionHook: this.mentionHook
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async post() {
|
|
||||||
const now = new Date();
|
|
||||||
if (now.getHours() !== 23) return;
|
|
||||||
const date = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
|
|
||||||
const data = this.getData();
|
|
||||||
if (data.lastPosted == date) return;
|
|
||||||
data.lastPosted = date;
|
|
||||||
this.setData(data);
|
|
||||||
|
|
||||||
this.log('Time to chart');
|
|
||||||
const file = await this.genChart('notes');
|
|
||||||
|
|
||||||
this.log('Posting...');
|
|
||||||
this.ai.post({
|
|
||||||
text: serifs.chart.post,
|
|
||||||
fileIds: [file.id]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async genChart(type, params?): Promise<any> {
|
|
||||||
this.log('Chart data fetching...');
|
|
||||||
|
|
||||||
let chart;
|
|
||||||
|
|
||||||
if (type === 'userNotes') {
|
|
||||||
const data = await this.ai.api('charts/user/notes', {
|
|
||||||
span: 'day',
|
|
||||||
limit: 30,
|
|
||||||
userId: params.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
chart = {
|
|
||||||
title: `@${params.user.username}さんの投稿数`,
|
|
||||||
datasets: [{
|
|
||||||
data: data.diffs.normal
|
|
||||||
}, {
|
|
||||||
data: data.diffs.reply
|
|
||||||
}, {
|
|
||||||
data: data.diffs.renote
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
} else if (type === 'followers') {
|
|
||||||
const data = await this.ai.api('charts/user/following', {
|
|
||||||
span: 'day',
|
|
||||||
limit: 30,
|
|
||||||
userId: params.user.id
|
|
||||||
});
|
|
||||||
|
|
||||||
chart = {
|
|
||||||
title: `@${params.user.username}さんのフォロワー数`,
|
|
||||||
datasets: [{
|
|
||||||
data: data.local.followers.total
|
|
||||||
}, {
|
|
||||||
data: data.remote.followers.total
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
} else if (type === 'notes') {
|
|
||||||
const data = await this.ai.api('charts/notes', {
|
|
||||||
span: 'day',
|
|
||||||
limit: 30,
|
|
||||||
});
|
|
||||||
|
|
||||||
chart = {
|
|
||||||
datasets: [{
|
|
||||||
data: data.local.diffs.normal
|
|
||||||
}, {
|
|
||||||
data: data.local.diffs.reply
|
|
||||||
}, {
|
|
||||||
data: data.local.diffs.renote
|
|
||||||
}]
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const suffixes = ['の売り上げ', 'の消費', 'の生産'];
|
|
||||||
|
|
||||||
const limit = 30;
|
|
||||||
const diffRange = 150;
|
|
||||||
const datasetCount = 1 + Math.floor(Math.random() * 3);
|
|
||||||
|
|
||||||
let datasets: any[] = [];
|
|
||||||
|
|
||||||
for (let d = 0; d < datasetCount; d++) {
|
|
||||||
let values = [Math.random() * 1000];
|
|
||||||
|
|
||||||
for (let i = 1; i < limit; i++) {
|
|
||||||
const prev = values[i - 1];
|
|
||||||
values.push(prev + ((Math.random() * (diffRange * 2)) - diffRange));
|
|
||||||
}
|
|
||||||
|
|
||||||
datasets.push({
|
|
||||||
data: values
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
chart = {
|
|
||||||
title: items[Math.floor(Math.random() * items.length)] + suffixes[Math.floor(Math.random() * suffixes.length)],
|
|
||||||
datasets: datasets
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log('Chart rendering...');
|
|
||||||
const img = renderChart(chart);
|
|
||||||
|
|
||||||
this.log('Image uploading...');
|
|
||||||
const file = await this.ai.upload(img, {
|
|
||||||
filename: 'chart.png',
|
|
||||||
contentType: 'image/png'
|
|
||||||
});
|
|
||||||
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async mentionHook(msg: Message) {
|
|
||||||
if (!msg.includes(['チャート'])) {
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
this.log('Chart requested');
|
|
||||||
}
|
|
||||||
|
|
||||||
let type = 'random';
|
|
||||||
if (msg.includes(['フォロワー'])) type = 'followers';
|
|
||||||
if (msg.includes(['投稿'])) type = 'userNotes';
|
|
||||||
|
|
||||||
const file = await this.genChart(type, {
|
|
||||||
user: msg.user
|
|
||||||
});
|
|
||||||
|
|
||||||
this.log('Replying...');
|
|
||||||
msg.reply(serifs.chart.foryou, { file });
|
|
||||||
|
|
||||||
return {
|
|
||||||
reaction: 'like'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,215 +0,0 @@
|
|||||||
import { createCanvas, registerFont } from 'canvas';
|
|
||||||
|
|
||||||
const width = 1024 + 256;
|
|
||||||
const height = 512 + 256;
|
|
||||||
const margin = 128;
|
|
||||||
const titleTextSize = 35;
|
|
||||||
|
|
||||||
const lineWidth = 16;
|
|
||||||
const yAxisThickness = 2;
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
bg: '#434343',
|
|
||||||
text: '#e0e4cc',
|
|
||||||
yAxis: '#5a5a5a',
|
|
||||||
dataset: [
|
|
||||||
'#ff4e50',
|
|
||||||
'#c2f725',
|
|
||||||
'#69d2e7',
|
|
||||||
'#f38630',
|
|
||||||
'#f9d423',
|
|
||||||
]
|
|
||||||
};
|
|
||||||
|
|
||||||
const yAxisTicks = 4;
|
|
||||||
|
|
||||||
type Chart = {
|
|
||||||
title?: string;
|
|
||||||
datasets: {
|
|
||||||
title?: string;
|
|
||||||
data: number[];
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function renderChart(chart: Chart) {
|
|
||||||
registerFont('./font.ttf', { family: 'CustomFont' });
|
|
||||||
|
|
||||||
const canvas = createCanvas(width, height);
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
ctx.antialias = 'default';
|
|
||||||
|
|
||||||
ctx.fillStyle = colors.bg;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
|
||||||
|
|
||||||
let chartAreaX = margin;
|
|
||||||
let chartAreaY = margin;
|
|
||||||
let chartAreaWidth = width - (margin * 2);
|
|
||||||
let chartAreaHeight = height - (margin * 2);
|
|
||||||
|
|
||||||
// Draw title
|
|
||||||
if (chart.title) {
|
|
||||||
ctx.font = `${titleTextSize}px CustomFont`;
|
|
||||||
const t = ctx.measureText(chart.title);
|
|
||||||
ctx.fillStyle = colors.text;
|
|
||||||
ctx.fillText(chart.title, (width / 2) - (t.width / 2), 128);
|
|
||||||
|
|
||||||
chartAreaY += titleTextSize;
|
|
||||||
chartAreaHeight -= titleTextSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
const xAxisCount = chart.datasets[0].data.length;
|
|
||||||
const serieses = chart.datasets.length;
|
|
||||||
|
|
||||||
let lowerBound = Infinity;
|
|
||||||
let upperBound = -Infinity;
|
|
||||||
|
|
||||||
for (let xAxis = 0; xAxis < xAxisCount; xAxis++) {
|
|
||||||
let v = 0;
|
|
||||||
for (let series = 0; series < serieses; series++) {
|
|
||||||
v += chart.datasets[series].data[xAxis];
|
|
||||||
}
|
|
||||||
if (v > upperBound) upperBound = v;
|
|
||||||
if (v < lowerBound) lowerBound = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate Y axis scale
|
|
||||||
const yAxisSteps = niceScale(lowerBound, upperBound, yAxisTicks);
|
|
||||||
const yAxisStepsMin = yAxisSteps[0];
|
|
||||||
const yAxisStepsMax = yAxisSteps[yAxisSteps.length - 1];
|
|
||||||
const yAxisRange = yAxisStepsMax - yAxisStepsMin;
|
|
||||||
|
|
||||||
// Draw Y axis
|
|
||||||
ctx.lineWidth = yAxisThickness;
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
ctx.strokeStyle = colors.yAxis;
|
|
||||||
for (let i = 0; i < yAxisSteps.length; i++) {
|
|
||||||
const step = yAxisSteps[yAxisSteps.length - i - 1];
|
|
||||||
const y = i * (chartAreaHeight / (yAxisSteps.length - 1));
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.lineTo(chartAreaX, chartAreaY + y);
|
|
||||||
ctx.lineTo(chartAreaX + chartAreaWidth, chartAreaY + y);
|
|
||||||
ctx.stroke();
|
|
||||||
|
|
||||||
ctx.font = '20px CustomFont';
|
|
||||||
ctx.fillStyle = colors.text;
|
|
||||||
ctx.fillText(step.toString(), chartAreaX, chartAreaY + y - 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newDatasets: any[] = [];
|
|
||||||
|
|
||||||
for (let series = 0; series < serieses; series++) {
|
|
||||||
newDatasets.push({
|
|
||||||
data: []
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let xAxis = 0; xAxis < xAxisCount; xAxis++) {
|
|
||||||
for (let series = 0; series < serieses; series++) {
|
|
||||||
newDatasets[series].data.push(chart.datasets[series].data[xAxis] / yAxisRange);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const perXAxisWidth = chartAreaWidth / xAxisCount;
|
|
||||||
|
|
||||||
let newUpperBound = -Infinity;
|
|
||||||
|
|
||||||
for (let xAxis = 0; xAxis < xAxisCount; xAxis++) {
|
|
||||||
let v = 0;
|
|
||||||
for (let series = 0; series < serieses; series++) {
|
|
||||||
v += newDatasets[series].data[xAxis];
|
|
||||||
}
|
|
||||||
if (v > newUpperBound) newUpperBound = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw X axis
|
|
||||||
ctx.lineWidth = lineWidth;
|
|
||||||
ctx.lineCap = 'round';
|
|
||||||
|
|
||||||
for (let xAxis = 0; xAxis < xAxisCount; xAxis++) {
|
|
||||||
const xAxisPerTypeHeights: number[] = [];
|
|
||||||
|
|
||||||
for (let series = 0; series < serieses; series++) {
|
|
||||||
const v = newDatasets[series].data[xAxis];
|
|
||||||
const vHeight = (v / newUpperBound) * (chartAreaHeight - ((yAxisStepsMax - upperBound) / yAxisStepsMax * chartAreaHeight));
|
|
||||||
xAxisPerTypeHeights.push(vHeight);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let series = serieses - 1; series >= 0; series--) {
|
|
||||||
ctx.strokeStyle = colors.dataset[series % colors.dataset.length];
|
|
||||||
|
|
||||||
let total = 0;
|
|
||||||
for (let i = 0; i < series; i++) {
|
|
||||||
total += xAxisPerTypeHeights[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
const height = xAxisPerTypeHeights[series];
|
|
||||||
|
|
||||||
const x = chartAreaX + (perXAxisWidth * ((xAxisCount - 1) - xAxis)) + (perXAxisWidth / 2);
|
|
||||||
|
|
||||||
const yTop = (chartAreaY + chartAreaHeight) - (total + height);
|
|
||||||
const yBottom = (chartAreaY + chartAreaHeight) - (total);
|
|
||||||
|
|
||||||
ctx.globalAlpha = 1 - (xAxis / xAxisCount);
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.lineTo(x, yTop);
|
|
||||||
ctx.lineTo(x, yBottom);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return canvas.toBuffer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://stackoverflow.com/questions/326679/choosing-an-attractive-linear-scale-for-a-graphs-y-axis
|
|
||||||
// https://github.com/apexcharts/apexcharts.js/blob/master/src/modules/Scales.js
|
|
||||||
// This routine creates the Y axis values for a graph.
|
|
||||||
function niceScale(lowerBound: number, upperBound: number, ticks: number): number[] {
|
|
||||||
if (lowerBound === 0 && upperBound === 0) return [0];
|
|
||||||
|
|
||||||
// Calculate Min amd Max graphical labels and graph
|
|
||||||
// increments. The number of ticks defaults to
|
|
||||||
// 10 which is the SUGGESTED value. Any tick value
|
|
||||||
// entered is used as a suggested value which is
|
|
||||||
// adjusted to be a 'pretty' value.
|
|
||||||
//
|
|
||||||
// Output will be an array of the Y axis values that
|
|
||||||
// encompass the Y values.
|
|
||||||
const steps: number[] = [];
|
|
||||||
|
|
||||||
// Determine Range
|
|
||||||
const range = upperBound - lowerBound;
|
|
||||||
|
|
||||||
let tiks = ticks + 1;
|
|
||||||
// Adjust ticks if needed
|
|
||||||
if (tiks < 2) {
|
|
||||||
tiks = 2;
|
|
||||||
} else if (tiks > 2) {
|
|
||||||
tiks -= 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get raw step value
|
|
||||||
const tempStep = range / tiks;
|
|
||||||
|
|
||||||
// Calculate pretty step value
|
|
||||||
const mag = Math.floor(Math.log10(tempStep));
|
|
||||||
const magPow = Math.pow(10, mag);
|
|
||||||
const magMsd = (parseInt as any)(tempStep / magPow);
|
|
||||||
const stepSize = magMsd * magPow;
|
|
||||||
|
|
||||||
// build Y label array.
|
|
||||||
// Lower and upper bounds calculations
|
|
||||||
const lb = stepSize * Math.floor(lowerBound / stepSize);
|
|
||||||
const ub = stepSize * Math.ceil(upperBound / stepSize);
|
|
||||||
// Build array
|
|
||||||
let val = lb;
|
|
||||||
while (1) {
|
|
||||||
steps.push(val);
|
|
||||||
val += stepSize;
|
|
||||||
if (val > ub) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return steps;
|
|
||||||
}
|
|
@ -1,40 +0,0 @@
|
|||||||
import autobind from 'autobind-decorator';
|
|
||||||
import Module from '@/module';
|
|
||||||
import Message from '@/message';
|
|
||||||
import serifs from '@/serifs';
|
|
||||||
|
|
||||||
export default class extends Module {
|
|
||||||
public readonly name = 'dice';
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public install() {
|
|
||||||
return {
|
|
||||||
mentionHook: this.mentionHook
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async mentionHook(msg: Message) {
|
|
||||||
if (msg.text == null) return false;
|
|
||||||
|
|
||||||
const query = msg.text.match(/([0-9]+)[dD]([0-9]+)/);
|
|
||||||
|
|
||||||
if (query == null) return false;
|
|
||||||
|
|
||||||
const times = parseInt(query[1], 10);
|
|
||||||
const dice = parseInt(query[2], 10);
|
|
||||||
|
|
||||||
if (times < 1 || times > 10) return false;
|
|
||||||
if (dice < 2 || dice > 1000) return false;
|
|
||||||
|
|
||||||
const results: number[] = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < times; i++) {
|
|
||||||
results.push(Math.floor(Math.random() * dice) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
msg.reply(serifs.dice.done(results.join(' ')));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,151 +0,0 @@
|
|||||||
import autobind from 'autobind-decorator';
|
|
||||||
import Module from '@/module';
|
|
||||||
import Message from '@/message';
|
|
||||||
import serifs from '@/serifs';
|
|
||||||
|
|
||||||
const hands = [
|
|
||||||
'👏',
|
|
||||||
'👍',
|
|
||||||
'👎',
|
|
||||||
'👊',
|
|
||||||
'✊',
|
|
||||||
['🤛', '🤜'],
|
|
||||||
['🤜', '🤛'],
|
|
||||||
'🤞',
|
|
||||||
'✌',
|
|
||||||
'🤟',
|
|
||||||
'🤘',
|
|
||||||
'👌',
|
|
||||||
'👈',
|
|
||||||
'👉',
|
|
||||||
['👈', '👉'],
|
|
||||||
['👉', '👈'],
|
|
||||||
'👆',
|
|
||||||
'👇',
|
|
||||||
'☝',
|
|
||||||
['✋', '🤚'],
|
|
||||||
'🖐',
|
|
||||||
'🖖',
|
|
||||||
'👋',
|
|
||||||
'🤙',
|
|
||||||
'💪',
|
|
||||||
['💪', '✌'],
|
|
||||||
'🖕'
|
|
||||||
]
|
|
||||||
|
|
||||||
const faces = [
|
|
||||||
'😀',
|
|
||||||
'😃',
|
|
||||||
'😄',
|
|
||||||
'😁',
|
|
||||||
'😆',
|
|
||||||
'😅',
|
|
||||||
'😂',
|
|
||||||
'🤣',
|
|
||||||
'☺️',
|
|
||||||
'😊',
|
|
||||||
'😇',
|
|
||||||
'🙂',
|
|
||||||
'🙃',
|
|
||||||
'😉',
|
|
||||||
'😌',
|
|
||||||
'😍',
|
|
||||||
'🥰',
|
|
||||||
'😘',
|
|
||||||
'😗',
|
|
||||||
'😙',
|
|
||||||
'😚',
|
|
||||||
'😋',
|
|
||||||
'😛',
|
|
||||||
'😝',
|
|
||||||
'😜',
|
|
||||||
'🤪',
|
|
||||||
'🤨',
|
|
||||||
'🧐',
|
|
||||||
'🤓',
|
|
||||||
'😎',
|
|
||||||
'🤩',
|
|
||||||
'🥳',
|
|
||||||
'😏',
|
|
||||||
'😒',
|
|
||||||
'😞',
|
|
||||||
'😔',
|
|
||||||
'😟',
|
|
||||||
'😕',
|
|
||||||
'🙁',
|
|
||||||
'☹️',
|
|
||||||
'😣',
|
|
||||||
'😖',
|
|
||||||
'😫',
|
|
||||||
'😩',
|
|
||||||
'🥺',
|
|
||||||
'😢',
|
|
||||||
'😭',
|
|
||||||
'😤',
|
|
||||||
'😠',
|
|
||||||
'😡',
|
|
||||||
'🤬',
|
|
||||||
'🤯',
|
|
||||||
'😳',
|
|
||||||
'😱',
|
|
||||||
'😨',
|
|
||||||
'😰',
|
|
||||||
'😥',
|
|
||||||
'😓',
|
|
||||||
'🤗',
|
|
||||||
'🤔',
|
|
||||||
'🤭',
|
|
||||||
'🤫',
|
|
||||||
'🤥',
|
|
||||||
'😶',
|
|
||||||
'😐',
|
|
||||||
'😑',
|
|
||||||
'😬',
|
|
||||||
'🙄',
|
|
||||||
'😯',
|
|
||||||
'😦',
|
|
||||||
'😧',
|
|
||||||
'😮',
|
|
||||||
'😲',
|
|
||||||
'😴',
|
|
||||||
'🤤',
|
|
||||||
'😪',
|
|
||||||
'😵',
|
|
||||||
'🤐',
|
|
||||||
'🥴',
|
|
||||||
'🤢',
|
|
||||||
'🤮',
|
|
||||||
'🤧',
|
|
||||||
'😷',
|
|
||||||
'🤒',
|
|
||||||
'🤕',
|
|
||||||
'🤑',
|
|
||||||
'🤠',
|
|
||||||
'🗿',
|
|
||||||
'🤖',
|
|
||||||
'👽'
|
|
||||||
]
|
|
||||||
|
|
||||||
export default class extends Module {
|
|
||||||
public readonly name = 'emoji';
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public install() {
|
|
||||||
return {
|
|
||||||
mentionHook: this.mentionHook
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async mentionHook(msg: Message) {
|
|
||||||
if (msg.includes(['顔文字', '絵文字', 'emoji', '福笑い'])) {
|
|
||||||
const hand = hands[Math.floor(Math.random() * hands.length)];
|
|
||||||
const face = faces[Math.floor(Math.random() * faces.length)];
|
|
||||||
const emoji = Array.isArray(hand) ? hand[0] + face + hand[1] : hand + face + hand;
|
|
||||||
msg.reply(serifs.emoji.suggest(emoji));
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,138 +0,0 @@
|
|||||||
import autobind from 'autobind-decorator';
|
|
||||||
import * as loki from 'lokijs';
|
|
||||||
import Module from '@/module';
|
|
||||||
import Message from '@/message';
|
|
||||||
import serifs from '@/serifs';
|
|
||||||
|
|
||||||
export default class extends Module {
|
|
||||||
public readonly name = 'guessingGame';
|
|
||||||
|
|
||||||
private guesses: loki.Collection<{
|
|
||||||
userId: string;
|
|
||||||
secret: number;
|
|
||||||
tries: number[];
|
|
||||||
isEnded: boolean;
|
|
||||||
startedAt: number;
|
|
||||||
endedAt: number | null;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public install() {
|
|
||||||
this.guesses = this.ai.getCollection('guessingGame', {
|
|
||||||
indices: ['userId']
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
mentionHook: this.mentionHook,
|
|
||||||
contextHook: this.contextHook
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async mentionHook(msg: Message) {
|
|
||||||
if (!msg.includes(['数当て', '数あて'])) return false;
|
|
||||||
|
|
||||||
const exist = this.guesses.findOne({
|
|
||||||
userId: msg.userId,
|
|
||||||
isEnded: false
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!msg.isDm) {
|
|
||||||
if (exist != null) {
|
|
||||||
msg.reply(serifs.guessingGame.alreadyStarted);
|
|
||||||
} else {
|
|
||||||
msg.reply(serifs.guessingGame.plzDm);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const secret = Math.floor(Math.random() * 100);
|
|
||||||
|
|
||||||
this.guesses.insertOne({
|
|
||||||
userId: msg.userId,
|
|
||||||
secret: secret,
|
|
||||||
tries: [],
|
|
||||||
isEnded: false,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
endedAt: null
|
|
||||||
});
|
|
||||||
|
|
||||||
msg.reply(serifs.guessingGame.started).then(reply => {
|
|
||||||
this.subscribeReply(msg.userId, msg.isDm, msg.isDm ? msg.userId : reply.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async contextHook(key: any, msg: Message) {
|
|
||||||
if (msg.text == null) return;
|
|
||||||
|
|
||||||
const exist = this.guesses.findOne({
|
|
||||||
userId: msg.userId,
|
|
||||||
isEnded: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// 処理の流れ上、実際にnullになることは無さそうだけど一応
|
|
||||||
if (exist == null) {
|
|
||||||
this.unsubscribeReply(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (msg.text.includes('やめ')) {
|
|
||||||
msg.reply(serifs.guessingGame.cancel);
|
|
||||||
exist.isEnded = true;
|
|
||||||
exist.endedAt = Date.now();
|
|
||||||
this.guesses.update(exist);
|
|
||||||
this.unsubscribeReply(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const guess = msg.extractedText.match(/[0-9]+/);
|
|
||||||
|
|
||||||
if (guess == null) {
|
|
||||||
msg.reply(serifs.guessingGame.nan).then(reply => {
|
|
||||||
this.subscribeReply(msg.userId, msg.isDm, reply.id);
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (guess.length > 3) return;
|
|
||||||
|
|
||||||
const g = parseInt(guess[0], 10);
|
|
||||||
const firsttime = exist.tries.indexOf(g) === -1;
|
|
||||||
|
|
||||||
exist.tries.push(g);
|
|
||||||
|
|
||||||
let text: string;
|
|
||||||
let end = false;
|
|
||||||
|
|
||||||
if (exist.secret < g) {
|
|
||||||
text = firsttime
|
|
||||||
? serifs.guessingGame.less(g.toString())
|
|
||||||
: serifs.guessingGame.lessAgain(g.toString());
|
|
||||||
} else if (exist.secret > g) {
|
|
||||||
text = firsttime
|
|
||||||
? serifs.guessingGame.grater(g.toString())
|
|
||||||
: serifs.guessingGame.graterAgain(g.toString());
|
|
||||||
} else {
|
|
||||||
end = true;
|
|
||||||
text = serifs.guessingGame.congrats(exist.tries.length.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
if (end) {
|
|
||||||
exist.isEnded = true;
|
|
||||||
exist.endedAt = Date.now();
|
|
||||||
this.unsubscribeReply(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.guesses.update(exist);
|
|
||||||
|
|
||||||
msg.reply(text).then(reply => {
|
|
||||||
if (!end) {
|
|
||||||
this.subscribeReply(msg.userId, msg.isDm, reply.id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,212 +0,0 @@
|
|||||||
import autobind from 'autobind-decorator';
|
|
||||||
import * as loki from 'lokijs';
|
|
||||||
import Module from '@/module';
|
|
||||||
import Message from '@/message';
|
|
||||||
import serifs from '@/serifs';
|
|
||||||
import { User } from '@/misskey/user';
|
|
||||||
import { acct } from '@/utils/acct';
|
|
||||||
|
|
||||||
type Game = {
|
|
||||||
votes: {
|
|
||||||
user: {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
host: User['host'];
|
|
||||||
};
|
|
||||||
number: number;
|
|
||||||
}[];
|
|
||||||
isEnded: boolean;
|
|
||||||
startedAt: number;
|
|
||||||
postId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const limitMinutes = 10;
|
|
||||||
|
|
||||||
export default class extends Module {
|
|
||||||
public readonly name = 'kazutori';
|
|
||||||
|
|
||||||
private games: loki.Collection<Game>;
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public install() {
|
|
||||||
this.games = this.ai.getCollection('kazutori');
|
|
||||||
|
|
||||||
this.crawleGameEnd();
|
|
||||||
setInterval(this.crawleGameEnd, 1000);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mentionHook: this.mentionHook,
|
|
||||||
contextHook: this.contextHook
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async mentionHook(msg: Message) {
|
|
||||||
if (!msg.includes(['数取り'])) return false;
|
|
||||||
|
|
||||||
const games = this.games.find({});
|
|
||||||
|
|
||||||
const recentGame = games.length == 0 ? null : games[games.length - 1];
|
|
||||||
|
|
||||||
if (recentGame) {
|
|
||||||
// 現在アクティブなゲームがある場合
|
|
||||||
if (!recentGame.isEnded) {
|
|
||||||
msg.reply(serifs.kazutori.alreadyStarted, {
|
|
||||||
renote: recentGame.postId
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 直近のゲームから1時間経ってない場合
|
|
||||||
if (Date.now() - recentGame.startedAt < 1000 * 60 * 60) {
|
|
||||||
msg.reply(serifs.kazutori.matakondo);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const post = await this.ai.post({
|
|
||||||
text: serifs.kazutori.intro(limitMinutes)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.games.insertOne({
|
|
||||||
votes: [],
|
|
||||||
isEnded: false,
|
|
||||||
startedAt: Date.now(),
|
|
||||||
postId: post.id
|
|
||||||
});
|
|
||||||
|
|
||||||
this.subscribeReply(null, false, post.id);
|
|
||||||
|
|
||||||
this.log('New kazutori game started');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async contextHook(key: any, msg: Message) {
|
|
||||||
if (msg.text == null) return {
|
|
||||||
reaction: 'hmm'
|
|
||||||
};
|
|
||||||
|
|
||||||
const game = this.games.findOne({
|
|
||||||
isEnded: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// 処理の流れ上、実際にnullになることは無さそうだけど一応
|
|
||||||
if (game == null) return;
|
|
||||||
|
|
||||||
// 既に数字を取っていたら
|
|
||||||
if (game.votes.some(x => x.user.id == msg.userId)) return {
|
|
||||||
reaction: 'confused'
|
|
||||||
};
|
|
||||||
|
|
||||||
const match = msg.extractedText.match(/[0-9]+/);
|
|
||||||
if (match == null) return {
|
|
||||||
reaction: 'hmm'
|
|
||||||
};
|
|
||||||
|
|
||||||
const num = parseInt(match[0], 10);
|
|
||||||
|
|
||||||
// 整数じゃない
|
|
||||||
if (!Number.isInteger(num)) return {
|
|
||||||
reaction: 'hmm'
|
|
||||||
};
|
|
||||||
|
|
||||||
// 範囲外
|
|
||||||
if (num < 0 || num > 100) return {
|
|
||||||
reaction: 'confused'
|
|
||||||
};
|
|
||||||
|
|
||||||
this.log(`Voted ${num} by ${msg.user.id}`);
|
|
||||||
|
|
||||||
// 投票
|
|
||||||
game.votes.push({
|
|
||||||
user: {
|
|
||||||
id: msg.user.id,
|
|
||||||
username: msg.user.username,
|
|
||||||
host: msg.user.host
|
|
||||||
},
|
|
||||||
number: num
|
|
||||||
});
|
|
||||||
|
|
||||||
this.games.update(game);
|
|
||||||
|
|
||||||
return {
|
|
||||||
reaction: 'like'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 終了すべきゲームがないかチェック
|
|
||||||
*/
|
|
||||||
@autobind
|
|
||||||
private crawleGameEnd() {
|
|
||||||
const game = this.games.findOne({
|
|
||||||
isEnded: false
|
|
||||||
});
|
|
||||||
|
|
||||||
if (game == null) return;
|
|
||||||
|
|
||||||
// 制限時間が経過していたら
|
|
||||||
if (Date.now() - game.startedAt >= 1000 * 60 * limitMinutes) {
|
|
||||||
this.finish(game);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ゲームを終わらせる
|
|
||||||
*/
|
|
||||||
@autobind
|
|
||||||
private finish(game: Game) {
|
|
||||||
game.isEnded = true;
|
|
||||||
this.games.update(game);
|
|
||||||
|
|
||||||
this.log('Kazutori game finished');
|
|
||||||
|
|
||||||
// お流れ
|
|
||||||
if (game.votes.length <= 1) {
|
|
||||||
this.ai.post({
|
|
||||||
text: serifs.kazutori.onagare,
|
|
||||||
renoteId: game.postId
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let results: string[] = [];
|
|
||||||
let winner: Game['votes'][0]['user'] | null = null;
|
|
||||||
|
|
||||||
for (let i = 100; i >= 0; i--) {
|
|
||||||
const users = game.votes
|
|
||||||
.filter(x => x.number == i)
|
|
||||||
.map(x => x.user);
|
|
||||||
|
|
||||||
if (users.length == 1) {
|
|
||||||
if (winner == null) {
|
|
||||||
winner = users[0];
|
|
||||||
const icon = i == 100 ? '💯' : '🎉';
|
|
||||||
results.push(`${icon} **${i}**: $[jelly ${acct(users[0])}]`);
|
|
||||||
} else {
|
|
||||||
results.push(`➖ ${i}: ${acct(users[0])}`);
|
|
||||||
}
|
|
||||||
} else if (users.length > 1) {
|
|
||||||
results.push(`❌ ${i}: ${users.map(u => acct(u)).join(' ')}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const winnerFriend = winner ? this.ai.lookupFriend(winner.id) : null;
|
|
||||||
const name = winnerFriend ? winnerFriend.name : null;
|
|
||||||
|
|
||||||
const text = results.join('\n') + '\n\n' + (winner
|
|
||||||
? serifs.kazutori.finishWithWinner(acct(winner), name)
|
|
||||||
: serifs.kazutori.finishWithNoWinner);
|
|
||||||
|
|
||||||
this.ai.post({
|
|
||||||
text: text,
|
|
||||||
cw: serifs.kazutori.finish,
|
|
||||||
renoteId: game.postId
|
|
||||||
});
|
|
||||||
|
|
||||||
this.unsubscribeReply(null);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,224 +0,0 @@
|
|||||||
import * as gen from 'random-seed';
|
|
||||||
import { CellType } from './maze';
|
|
||||||
|
|
||||||
const cellVariants = {
|
|
||||||
void: {
|
|
||||||
digg: { left: null, right: null, top: null, bottom: null },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
empty: {
|
|
||||||
digg: { left: 'left', right: 'right', top: 'top', bottom: 'bottom' },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
left: {
|
|
||||||
digg: { left: null, right: 'leftRight', top: 'leftTop', bottom: 'leftBottom' },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
right: {
|
|
||||||
digg: { left: 'leftRight', right: null, top: 'rightTop', bottom: 'rightBottom' },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
top: {
|
|
||||||
digg: { left: 'leftTop', right: 'rightTop', top: null, bottom: 'topBottom' },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
bottom: {
|
|
||||||
digg: { left: 'leftBottom', right: 'rightBottom', top: 'topBottom', bottom: null },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
leftTop: {
|
|
||||||
digg: { left: null, right: 'leftRightTop', top: null, bottom: 'leftTopBottom' },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
leftBottom: {
|
|
||||||
digg: { left: null, right: 'leftRightBottom', top: 'leftTopBottom', bottom: null },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
rightTop: {
|
|
||||||
digg: { left: 'leftRightTop', right: null, top: null, bottom: 'rightTopBottom' },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
rightBottom: {
|
|
||||||
digg: { left: 'leftRightBottom', right: null, top: 'rightTopBottom', bottom: null },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
leftRightTop: {
|
|
||||||
digg: { left: null, right: null, top: null, bottom: null },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
leftRightBottom: {
|
|
||||||
digg: { left: null, right: null, top: null, bottom: null },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
leftTopBottom: {
|
|
||||||
digg: { left: null, right: null, top: null, bottom: null },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
rightTopBottom: {
|
|
||||||
digg: { left: null, right: null, top: null, bottom: null },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
leftRight: {
|
|
||||||
digg: { left: null, right: null, top: 'leftRightTop', bottom: 'leftRightBottom' },
|
|
||||||
cross: { left: false, right: false, top: true, bottom: true },
|
|
||||||
},
|
|
||||||
topBottom: {
|
|
||||||
digg: { left: 'leftTopBottom', right: 'rightTopBottom', top: null, bottom: null },
|
|
||||||
cross: { left: true, right: true, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
cross: {
|
|
||||||
digg: { left: 'cross', right: 'cross', top: 'cross', bottom: 'cross' },
|
|
||||||
cross: { left: false, right: false, top: false, bottom: false },
|
|
||||||
},
|
|
||||||
} as { [k in CellType]: {
|
|
||||||
digg: { left: CellType | null; right: CellType | null; top: CellType | null; bottom: CellType | null; };
|
|
||||||
cross: { left: boolean; right: boolean; top: boolean; bottom: boolean; };
|
|
||||||
} };
|
|
||||||
|
|
||||||
type Dir = 'left' | 'right' | 'top' | 'bottom';
|
|
||||||
|
|
||||||
export function genMaze(seed, complexity?) {
|
|
||||||
const rand = gen.create(seed);
|
|
||||||
|
|
||||||
let mazeSize;
|
|
||||||
if (complexity) {
|
|
||||||
if (complexity === 'veryEasy') mazeSize = 3 + rand(3);
|
|
||||||
if (complexity === 'easy') mazeSize = 8 + rand(8);
|
|
||||||
if (complexity === 'hard') mazeSize = 22 + rand(13);
|
|
||||||
if (complexity === 'veryHard') mazeSize = 40 + rand(20);
|
|
||||||
if (complexity === 'ai') mazeSize = 100;
|
|
||||||
} else {
|
|
||||||
mazeSize = 11 + rand(21);
|
|
||||||
}
|
|
||||||
|
|
||||||
const donut = rand(3) === 0;
|
|
||||||
const donutWidth = 1 + Math.floor(mazeSize / 8) + rand(Math.floor(mazeSize / 4));
|
|
||||||
|
|
||||||
const straightMode = rand(3) === 0;
|
|
||||||
const straightness = 5 + rand(10);
|
|
||||||
|
|
||||||
// maze (filled by 'empty')
|
|
||||||
const maze: CellType[][] = new Array(mazeSize);
|
|
||||||
for (let i = 0; i < mazeSize; i++) {
|
|
||||||
maze[i] = new Array(mazeSize).fill('empty');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (donut) {
|
|
||||||
for (let y = 0; y < mazeSize; y++) {
|
|
||||||
for (let x = 0; x < mazeSize; x++) {
|
|
||||||
if (x > donutWidth && x < (mazeSize - 1) - donutWidth && y > donutWidth && y < (mazeSize - 1) - donutWidth) {
|
|
||||||
maze[x][y] = 'void';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkDiggable(x: number, y: number, dir: Dir) {
|
|
||||||
if (cellVariants[maze[x][y]].digg[dir] === null) return false;
|
|
||||||
|
|
||||||
const newPos =
|
|
||||||
dir === 'top' ? { x: x, y: y - 1 } :
|
|
||||||
dir === 'bottom' ? { x: x, y: y + 1 } :
|
|
||||||
dir === 'left' ? { x: x - 1, y: y } :
|
|
||||||
dir === 'right' ? { x: x + 1, y: y } :
|
|
||||||
{ x, y };
|
|
||||||
|
|
||||||
if (newPos.x < 0 || newPos.y < 0 || newPos.x >= mazeSize || newPos.y >= mazeSize) return false;
|
|
||||||
|
|
||||||
const cell = maze[newPos.x][newPos.y];
|
|
||||||
if (cell === 'void') return false;
|
|
||||||
if (cell === 'empty') return true;
|
|
||||||
if (cellVariants[cell].cross[dir] && checkDiggable(newPos.x, newPos.y, dir)) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function diggFrom(x: number, y: number, prevDir?: Dir) {
|
|
||||||
const isUpDiggable = checkDiggable(x, y, 'top');
|
|
||||||
const isRightDiggable = checkDiggable(x, y, 'right');
|
|
||||||
const isDownDiggable = checkDiggable(x, y, 'bottom');
|
|
||||||
const isLeftDiggable = checkDiggable(x, y, 'left');
|
|
||||||
|
|
||||||
if (!isUpDiggable && !isRightDiggable && !isDownDiggable && !isLeftDiggable) return;
|
|
||||||
|
|
||||||
const dirs: Dir[] = [];
|
|
||||||
if (isUpDiggable) dirs.push('top');
|
|
||||||
if (isRightDiggable) dirs.push('right');
|
|
||||||
if (isDownDiggable) dirs.push('bottom');
|
|
||||||
if (isLeftDiggable) dirs.push('left');
|
|
||||||
|
|
||||||
let dir: Dir;
|
|
||||||
if (straightMode && rand(straightness) !== 0) {
|
|
||||||
if (prevDir != null && dirs.includes(prevDir)) {
|
|
||||||
dir = prevDir;
|
|
||||||
} else {
|
|
||||||
dir = dirs[rand(dirs.length)];
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dir = dirs[rand(dirs.length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
maze[x][y] = cellVariants[maze[x][y]].digg[dir]!;
|
|
||||||
|
|
||||||
if (dir === 'top') {
|
|
||||||
maze[x][y - 1] = maze[x][y - 1] === 'empty' ? 'bottom' : 'cross';
|
|
||||||
diggFrom(x, y - 1, dir);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dir === 'right') {
|
|
||||||
maze[x + 1][y] = maze[x + 1][y] === 'empty' ? 'left' : 'cross';
|
|
||||||
diggFrom(x + 1, y, dir);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dir === 'bottom') {
|
|
||||||
maze[x][y + 1] = maze[x][y + 1] === 'empty' ? 'top' : 'cross';
|
|
||||||
diggFrom(x, y + 1, dir);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (dir === 'left') {
|
|
||||||
maze[x - 1][y] = maze[x - 1][y] === 'empty' ? 'right' : 'cross';
|
|
||||||
diggFrom(x - 1, y, dir);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//#region start digg
|
|
||||||
const nonVoidCells: [number, number][] = [];
|
|
||||||
|
|
||||||
for (let y = 0; y < mazeSize; y++) {
|
|
||||||
for (let x = 0; x < mazeSize; x++) {
|
|
||||||
const cell = maze[x][y];
|
|
||||||
if (cell !== 'void') nonVoidCells.push([x, y]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const origin = nonVoidCells[rand(nonVoidCells.length)];
|
|
||||||
|
|
||||||
diggFrom(origin[0], origin[1]);
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
let hasEmptyCell = true;
|
|
||||||
while (hasEmptyCell) {
|
|
||||||
const nonEmptyCells: [number, number][] = [];
|
|
||||||
|
|
||||||
for (let y = 0; y < mazeSize; y++) {
|
|
||||||
for (let x = 0; x < mazeSize; x++) {
|
|
||||||
const cell = maze[x][y];
|
|
||||||
if (cell !== 'empty' && cell !== 'void' && cell !== 'cross') nonEmptyCells.push([x, y]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const pos = nonEmptyCells[rand(nonEmptyCells.length)];
|
|
||||||
|
|
||||||
diggFrom(pos[0], pos[1]);
|
|
||||||
|
|
||||||
hasEmptyCell = false;
|
|
||||||
for (let y = 0; y < mazeSize; y++) {
|
|
||||||
for (let x = 0; x < mazeSize; x++) {
|
|
||||||
if (maze[x][y] === 'empty') hasEmptyCell = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return maze;
|
|
||||||
}
|
|
@ -1,80 +0,0 @@
|
|||||||
import autobind from 'autobind-decorator';
|
|
||||||
import Module from '@/module';
|
|
||||||
import serifs from '@/serifs';
|
|
||||||
import { genMaze } from './gen-maze';
|
|
||||||
import { renderMaze } from './render-maze';
|
|
||||||
import Message from '@/message';
|
|
||||||
|
|
||||||
export default class extends Module {
|
|
||||||
public readonly name = 'maze';
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public install() {
|
|
||||||
this.post();
|
|
||||||
setInterval(this.post, 1000 * 60 * 3);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mentionHook: this.mentionHook
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async post() {
|
|
||||||
const now = new Date();
|
|
||||||
if (now.getHours() !== 22) return;
|
|
||||||
const date = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
|
|
||||||
const data = this.getData();
|
|
||||||
if (data.lastPosted == date) return;
|
|
||||||
data.lastPosted = date;
|
|
||||||
this.setData(data);
|
|
||||||
|
|
||||||
this.log('Time to maze');
|
|
||||||
const file = await this.genMazeFile(date);
|
|
||||||
|
|
||||||
this.log('Posting...');
|
|
||||||
this.ai.post({
|
|
||||||
text: serifs.maze.post,
|
|
||||||
fileIds: [file.id]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async genMazeFile(seed, size?): Promise<any> {
|
|
||||||
this.log('Maze generating...');
|
|
||||||
const maze = genMaze(seed, size);
|
|
||||||
|
|
||||||
this.log('Maze rendering...');
|
|
||||||
const data = renderMaze(seed, maze);
|
|
||||||
|
|
||||||
this.log('Image uploading...');
|
|
||||||
const file = await this.ai.upload(data, {
|
|
||||||
filename: 'maze.png',
|
|
||||||
contentType: 'image/png'
|
|
||||||
});
|
|
||||||
|
|
||||||
return file;
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async mentionHook(msg: Message) {
|
|
||||||
if (msg.includes(['迷路'])) {
|
|
||||||
let size: string | null = null;
|
|
||||||
if (msg.includes(['接待'])) size = 'veryEasy';
|
|
||||||
if (msg.includes(['簡単', 'かんたん', '易しい', 'やさしい', '小さい', 'ちいさい'])) size = 'easy';
|
|
||||||
if (msg.includes(['難しい', 'むずかしい', '複雑な', '大きい', 'おおきい'])) size = 'hard';
|
|
||||||
if (msg.includes(['死', '鬼', '地獄'])) size = 'veryHard';
|
|
||||||
if (msg.includes(['藍']) && msg.includes(['本気'])) size = 'ai';
|
|
||||||
this.log('Maze requested');
|
|
||||||
setTimeout(async () => {
|
|
||||||
const file = await this.genMazeFile(Date.now(), size);
|
|
||||||
this.log('Replying...');
|
|
||||||
msg.reply(serifs.maze.foryou, { file });
|
|
||||||
}, 3000);
|
|
||||||
return {
|
|
||||||
reaction: 'like'
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
export type CellType = 'void' | 'empty' | 'left' | 'right' | 'top' | 'bottom' | 'leftTop' | 'leftBottom' | 'rightTop' | 'rightBottom' | 'leftRightTop' | 'leftRightBottom' | 'leftTopBottom' | 'rightTopBottom' | 'leftRight' | 'topBottom' | 'cross';
|
|
@ -1,243 +0,0 @@
|
|||||||
import * as gen from 'random-seed';
|
|
||||||
import { createCanvas } from 'canvas';
|
|
||||||
|
|
||||||
import { CellType } from './maze';
|
|
||||||
import { themes } from './themes';
|
|
||||||
|
|
||||||
const imageSize = 4096; // px
|
|
||||||
const margin = 96 * 4;
|
|
||||||
const mazeAreaSize = imageSize - (margin * 2);
|
|
||||||
|
|
||||||
export function renderMaze(seed, maze: CellType[][]) {
|
|
||||||
const rand = gen.create(seed);
|
|
||||||
const mazeSize = maze.length;
|
|
||||||
|
|
||||||
const colors = themes[rand(themes.length)];
|
|
||||||
|
|
||||||
const canvas = createCanvas(imageSize, imageSize);
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
ctx.antialias = 'none';
|
|
||||||
|
|
||||||
ctx.fillStyle = colors.bg1;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.fillRect(0, 0, imageSize, imageSize);
|
|
||||||
|
|
||||||
ctx.fillStyle = colors.bg2;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.fillRect(margin / 2, margin / 2, imageSize - ((margin / 2) * 2), imageSize - ((margin / 2) * 2));
|
|
||||||
|
|
||||||
// Draw
|
|
||||||
function drawCell(ctx, x, y, size, left, right, top, bottom, mark) {
|
|
||||||
const wallThickness = size / 6;
|
|
||||||
const margin = size / 6;
|
|
||||||
const markerMargin = size / 3;
|
|
||||||
|
|
||||||
ctx.fillStyle = colors.road;
|
|
||||||
if (left) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.fillRect(x, y + margin, size - margin, size - (margin * 2));
|
|
||||||
}
|
|
||||||
if (right) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.fillRect(x + margin, y + margin, size - margin, size - (margin * 2));
|
|
||||||
}
|
|
||||||
if (top) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.fillRect(x + margin, y, size - (margin * 2), size - margin);
|
|
||||||
}
|
|
||||||
if (bottom) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.fillRect(x + margin, y + margin, size - (margin * 2), size - margin);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mark) {
|
|
||||||
ctx.fillStyle = colors.marker;
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.fillRect(x + markerMargin, y + markerMargin, size - (markerMargin * 2), size - (markerMargin * 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.strokeStyle = colors.wall;
|
|
||||||
ctx.lineWidth = wallThickness;
|
|
||||||
ctx.lineCap = 'square';
|
|
||||||
|
|
||||||
function line(ax, ay, bx, by) {
|
|
||||||
ctx.beginPath();
|
|
||||||
ctx.lineTo(x + ax, y + ay);
|
|
||||||
ctx.lineTo(x + bx, y + by);
|
|
||||||
ctx.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (left && right && top && bottom) {
|
|
||||||
ctx.beginPath();
|
|
||||||
if (rand(2) === 0) {
|
|
||||||
line(0, margin, size, margin); // ─ 上
|
|
||||||
line(0, size - margin, size, size - margin); // ─ 下
|
|
||||||
line(margin, 0, margin, margin); // │ 左上
|
|
||||||
line(size - margin, 0, size - margin, margin); // │ 右上
|
|
||||||
line(margin, size - margin, margin, size); // │ 左下
|
|
||||||
line(size - margin, size - margin, size - margin, size); // │ 右下
|
|
||||||
} else {
|
|
||||||
line(margin, 0, margin, size); // │ 左
|
|
||||||
line(size - margin, 0, size - margin, size); // │ 右
|
|
||||||
line(0, margin, margin, margin); // ─ 左上
|
|
||||||
line(size - margin, margin, size, margin); // ─ 右上
|
|
||||||
line(0, size - margin, margin, size - margin); // ─ 左下
|
|
||||||
line(size - margin, size - margin, size, size - margin); // ─ 右下
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─
|
|
||||||
if (left && right && !top && !bottom) {
|
|
||||||
line(0, margin, size, margin); // ─ 上
|
|
||||||
line(0, size - margin, size, size - margin); // ─ 下
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// │
|
|
||||||
if (!left && !right && top && bottom) {
|
|
||||||
line(margin, 0, margin, size); // │ 左
|
|
||||||
line(size - margin, 0, size - margin, size); // │ 右
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 左行き止まり
|
|
||||||
if (!left && right && !top && !bottom) {
|
|
||||||
line(margin, margin, size, margin); // ─ 上
|
|
||||||
line(margin, margin, margin, size - margin); // │ 左
|
|
||||||
line(margin, size - margin, size, size - margin); // ─ 下
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 右行き止まり
|
|
||||||
if (left && !right && !top && !bottom) {
|
|
||||||
line(0, margin, size - margin, margin); // ─ 上
|
|
||||||
line(size - margin, margin, size - margin, size - margin); // │ 右
|
|
||||||
line(0, size - margin, size - margin, size - margin); // ─ 下
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 上行き止まり
|
|
||||||
if (!left && !right && !top && bottom) {
|
|
||||||
line(margin, margin, size - margin, margin); // ─ 上
|
|
||||||
line(margin, margin, margin, size); // │ 左
|
|
||||||
line(size - margin, margin, size - margin, size); // │ 右
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 下行き止まり
|
|
||||||
if (!left && !right && top && !bottom) {
|
|
||||||
line(margin, size - margin, size - margin, size - margin); // ─ 下
|
|
||||||
line(margin, 0, margin, size - margin); // │ 左
|
|
||||||
line(size - margin, 0, size - margin, size - margin); // │ 右
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ┌
|
|
||||||
if (!left && !top && right && bottom) {
|
|
||||||
line(margin, margin, size, margin); // ─ 上
|
|
||||||
line(margin, margin, margin, size); // │ 左
|
|
||||||
line(size - margin, size - margin, size, size - margin); // ─ 下
|
|
||||||
line(size - margin, size - margin, size - margin, size); // │ 右
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ┐
|
|
||||||
if (left && !right && !top && bottom) {
|
|
||||||
line(0, margin, size - margin, margin); // ─ 上
|
|
||||||
line(size - margin, margin, size - margin, size); // │ 右
|
|
||||||
line(0, size - margin, margin, size - margin); // ─ 下
|
|
||||||
line(margin, size - margin, margin, size); // │ 左
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// └
|
|
||||||
if (!left && right && top && !bottom) {
|
|
||||||
line(margin, 0, margin, size - margin); // │ 左
|
|
||||||
line(margin, size - margin, size, size - margin); // ─ 下
|
|
||||||
line(size - margin, 0, size - margin, margin); // │ 右
|
|
||||||
line(size - margin, margin, size, margin); // ─ 上
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ┘
|
|
||||||
if (left && !right && top && !bottom) {
|
|
||||||
line(margin, 0, margin, margin); // │ 左
|
|
||||||
line(0, margin, margin, margin); // ─ 上
|
|
||||||
line(size - margin, 0, size - margin, size - margin); // │ 右
|
|
||||||
line(0, size - margin, size - margin, size - margin); // ─ 下
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ├
|
|
||||||
if (!left && right && top && bottom) {
|
|
||||||
line(margin, 0, margin, size); // │ 左
|
|
||||||
line(size - margin, 0, size - margin, margin); // │ 右
|
|
||||||
line(size - margin, margin, size, margin); // ─ 上
|
|
||||||
line(size - margin, size - margin, size, size - margin); // ─ 下
|
|
||||||
line(size - margin, size - margin, size - margin, size); // │ 右
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ┤
|
|
||||||
if (left && !right && top && bottom) {
|
|
||||||
line(size - margin, 0, size - margin, size); // │ 右
|
|
||||||
line(margin, 0, margin, margin); // │ 左
|
|
||||||
line(0, margin, margin, margin); // ─ 上
|
|
||||||
line(0, size - margin, margin, size - margin); // ─ 下
|
|
||||||
line(margin, size - margin, margin, size); // │ 左
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ┬
|
|
||||||
if (left && right && !top && bottom) {
|
|
||||||
line(0, margin, size, margin); // ─ 上
|
|
||||||
line(0, size - margin, margin, size - margin); // ─ 下
|
|
||||||
line(margin, size - margin, margin, size); // │ 左
|
|
||||||
line(size - margin, size - margin, size, size - margin); // ─ 下
|
|
||||||
line(size - margin, size - margin, size - margin, size); // │ 右
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ┴
|
|
||||||
if (left && right && top && !bottom) {
|
|
||||||
line(0, size - margin, size, size - margin); // ─ 下
|
|
||||||
line(margin, 0, margin, margin); // │ 左
|
|
||||||
line(0, margin, margin, margin); // ─ 上
|
|
||||||
line(size - margin, 0, size - margin, margin); // │ 右
|
|
||||||
line(size - margin, margin, size, margin); // ─ 上
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cellSize = mazeAreaSize / mazeSize;
|
|
||||||
|
|
||||||
for (let x = 0; x < mazeSize; x++) {
|
|
||||||
for (let y = 0; y < mazeSize; y++) {
|
|
||||||
const actualX = margin + (cellSize * x);
|
|
||||||
const actualY = margin + (cellSize * y);
|
|
||||||
|
|
||||||
const cell = maze[x][y];
|
|
||||||
|
|
||||||
const mark = (x === 0 && y === 0) || (x === mazeSize - 1 && y === mazeSize - 1);
|
|
||||||
|
|
||||||
if (cell === 'left') drawCell(ctx, actualX, actualY, cellSize, true, false, false, false, mark);
|
|
||||||
if (cell === 'right') drawCell(ctx, actualX, actualY, cellSize, false, true, false, false, mark);
|
|
||||||
if (cell === 'top') drawCell(ctx, actualX, actualY, cellSize, false, false, true, false, mark);
|
|
||||||
if (cell === 'bottom') drawCell(ctx, actualX, actualY, cellSize, false, false, false, true, mark);
|
|
||||||
if (cell === 'leftTop') drawCell(ctx, actualX, actualY, cellSize, true, false, true, false, mark);
|
|
||||||
if (cell === 'leftBottom') drawCell(ctx, actualX, actualY, cellSize, true, false, false, true, mark);
|
|
||||||
if (cell === 'rightTop') drawCell(ctx, actualX, actualY, cellSize, false, true, true, false, mark);
|
|
||||||
if (cell === 'rightBottom') drawCell(ctx, actualX, actualY, cellSize, false, true, false, true, mark);
|
|
||||||
if (cell === 'leftRightTop') drawCell(ctx, actualX, actualY, cellSize, true, true, true, false, mark);
|
|
||||||
if (cell === 'leftRightBottom') drawCell(ctx, actualX, actualY, cellSize, true, true, false, true, mark);
|
|
||||||
if (cell === 'leftTopBottom') drawCell(ctx, actualX, actualY, cellSize, true, false, true, true, mark);
|
|
||||||
if (cell === 'rightTopBottom') drawCell(ctx, actualX, actualY, cellSize, false, true, true, true, mark);
|
|
||||||
if (cell === 'leftRight') drawCell(ctx, actualX, actualY, cellSize, true, true, false, false, mark);
|
|
||||||
if (cell === 'topBottom') drawCell(ctx, actualX, actualY, cellSize, false, false, true, true, mark);
|
|
||||||
if (cell === 'cross') drawCell(ctx, actualX, actualY, cellSize, true, true, true, true, mark);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return canvas.toBuffer();
|
|
||||||
}
|
|
@ -1,55 +0,0 @@
|
|||||||
export const themes = [{
|
|
||||||
bg1: '#C1D9CE',
|
|
||||||
bg2: '#F2EDD5',
|
|
||||||
wall: '#0F8AA6',
|
|
||||||
road: '#C1D9CE',
|
|
||||||
marker: '#84BFBF',
|
|
||||||
}, {
|
|
||||||
bg1: '#17275B',
|
|
||||||
bg2: '#1F2E67',
|
|
||||||
wall: '#17275B',
|
|
||||||
road: '#6A77A4',
|
|
||||||
marker: '#E6E5E3',
|
|
||||||
}, {
|
|
||||||
bg1: '#BFD962',
|
|
||||||
bg2: '#EAF2AC',
|
|
||||||
wall: '#1E4006',
|
|
||||||
road: '#BFD962',
|
|
||||||
marker: '#74A608',
|
|
||||||
}, {
|
|
||||||
bg1: '#C0CCB8',
|
|
||||||
bg2: '#FFE2C0',
|
|
||||||
wall: '#664A3C',
|
|
||||||
road: '#FFCB99',
|
|
||||||
marker: '#E78F72',
|
|
||||||
}, {
|
|
||||||
bg1: '#101010',
|
|
||||||
bg2: '#151515',
|
|
||||||
wall: '#909090',
|
|
||||||
road: '#202020',
|
|
||||||
marker: '#606060',
|
|
||||||
}, {
|
|
||||||
bg1: '#e0e0e0',
|
|
||||||
bg2: '#f2f2f2',
|
|
||||||
wall: '#a0a0a0',
|
|
||||||
road: '#e0e0e0',
|
|
||||||
marker: '#707070',
|
|
||||||
}, {
|
|
||||||
bg1: '#7DE395',
|
|
||||||
bg2: '#D0F3CF',
|
|
||||||
wall: '#349D9E',
|
|
||||||
road: '#7DE395',
|
|
||||||
marker: '#56C495',
|
|
||||||
}, {
|
|
||||||
bg1: '#C9EEEA',
|
|
||||||
bg2: '#DBF4F1',
|
|
||||||
wall: '#4BC6B9',
|
|
||||||
road: '#C9EEEA',
|
|
||||||
marker: '#19A89D',
|
|
||||||
}, {
|
|
||||||
bg1: '#1e231b',
|
|
||||||
bg2: '#27331e',
|
|
||||||
wall: '#67b231',
|
|
||||||
road: '#385622',
|
|
||||||
marker: '#78d337',
|
|
||||||
}];
|
|
@ -1,146 +0,0 @@
|
|||||||
import autobind from 'autobind-decorator';
|
|
||||||
import Message from '@/message';
|
|
||||||
import Module from '@/module';
|
|
||||||
import serifs from '@/serifs';
|
|
||||||
import { genItem } from '@/vocabulary';
|
|
||||||
import config from '@/config';
|
|
||||||
import { Note } from '@/misskey/note';
|
|
||||||
|
|
||||||
export default class extends Module {
|
|
||||||
public readonly name = 'poll';
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public install() {
|
|
||||||
setInterval(() => {
|
|
||||||
if (Math.random() < 0.1) {
|
|
||||||
this.post();
|
|
||||||
}
|
|
||||||
}, 1000 * 60 * 60);
|
|
||||||
|
|
||||||
return {
|
|
||||||
mentionHook: this.mentionHook,
|
|
||||||
timeoutCallback: this.timeoutCallback,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async post() {
|
|
||||||
const duration = 1000 * 60 * 15;
|
|
||||||
|
|
||||||
const polls = [ // TODO: Extract serif
|
|
||||||
['珍しそうなもの', 'みなさんは、どれがいちばん珍しいと思いますか?'],
|
|
||||||
['美味しそうなもの', 'みなさんは、どれがいちばん美味しいと思いますか?'],
|
|
||||||
['重そうなもの', 'みなさんは、どれがいちばん重いと思いますか?'],
|
|
||||||
['欲しいもの', 'みなさんは、どれがいちばん欲しいですか?'],
|
|
||||||
['無人島に持っていきたいもの', 'みなさんは、無人島にひとつ持っていけるとしたらどれにしますか?'],
|
|
||||||
['家に飾りたいもの', 'みなさんは、家に飾るとしたらどれにしますか?'],
|
|
||||||
['売れそうなもの', 'みなさんは、どれがいちばん売れそうだと思いますか?'],
|
|
||||||
['降ってきてほしいもの', 'みなさんは、どれが空から降ってきてほしいですか?'],
|
|
||||||
['携帯したいもの', 'みなさんは、どれを携帯したいですか?'],
|
|
||||||
['商品化したいもの', 'みなさんは、商品化するとしたらどれにしますか?'],
|
|
||||||
['発掘されそうなもの', 'みなさんは、遺跡から発掘されそうなものはどれだと思いますか?'],
|
|
||||||
['良い香りがしそうなもの', 'みなさんは、どれがいちばんいい香りがすると思いますか?'],
|
|
||||||
['高値で取引されそうなもの', 'みなさんは、どれがいちばん高値で取引されると思いますか?'],
|
|
||||||
['地球周回軌道上にありそうなもの', 'みなさんは、どれが地球周回軌道上を漂っていそうだと思いますか?'],
|
|
||||||
['プレゼントしたいもの', 'みなさんは、私にプレゼントしてくれるとしたらどれにしますか?'],
|
|
||||||
['プレゼントされたいもの', 'みなさんは、プレゼントでもらうとしたらどれにしますか?'],
|
|
||||||
['私が持ってそうなもの', 'みなさんは、私が持ってそうなものはどれだと思いますか?'],
|
|
||||||
['流行りそうなもの', 'みなさんは、どれが流行りそうだと思いますか?'],
|
|
||||||
['朝ごはん', 'みなさんは、朝ごはんにどれが食べたいですか?'],
|
|
||||||
['お昼ごはん', 'みなさんは、お昼ごはんにどれが食べたいですか?'],
|
|
||||||
['お夕飯', 'みなさんは、お夕飯にどれが食べたいですか?'],
|
|
||||||
['体に良さそうなもの', 'みなさんは、どれが体に良さそうだと思いますか?'],
|
|
||||||
['後世に遺したいもの', 'みなさんは、どれを後世に遺したいですか?'],
|
|
||||||
['楽器になりそうなもの', 'みなさんは、どれが楽器になりそうだと思いますか?'],
|
|
||||||
['お味噌汁の具にしたいもの', 'みなさんは、お味噌汁の具にするとしたらどれがいいですか?'],
|
|
||||||
['ふりかけにしたいもの', 'みなさんは、どれをごはんにふりかけたいですか?'],
|
|
||||||
['よく見かけるもの', 'みなさんは、どれをよく見かけますか?'],
|
|
||||||
['道に落ちてそうなもの', 'みなさんは、道端に落ちてそうなものはどれだと思いますか?'],
|
|
||||||
['美術館に置いてそうなもの', 'みなさんは、この中で美術館に置いてありそうなものはどれだと思いますか?'],
|
|
||||||
['教室にありそうなもの', 'みなさんは、教室にありそうなものってどれだと思いますか?'],
|
|
||||||
['絵文字になってほしいもの', '絵文字になってほしいものはどれですか?'],
|
|
||||||
['Misskey本部にありそうなもの', 'みなさんは、Misskey本部にありそうなものはどれだと思いますか?'],
|
|
||||||
['燃えるゴミ', 'みなさんは、どれが燃えるゴミだと思いますか?'],
|
|
||||||
['好きなおにぎりの具', 'みなさんの好きなおにぎりの具はなんですか?'],
|
|
||||||
];
|
|
||||||
|
|
||||||
const poll = polls[Math.floor(Math.random() * polls.length)];
|
|
||||||
|
|
||||||
const choices = [
|
|
||||||
genItem(),
|
|
||||||
genItem(),
|
|
||||||
genItem(),
|
|
||||||
genItem(),
|
|
||||||
];
|
|
||||||
|
|
||||||
const note = await this.ai.post({
|
|
||||||
text: poll[1],
|
|
||||||
poll: {
|
|
||||||
choices,
|
|
||||||
expiredAfter: duration,
|
|
||||||
multiple: false,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// タイマーセット
|
|
||||||
this.setTimeoutWithPersistence(duration + 3000, {
|
|
||||||
title: poll[0],
|
|
||||||
noteId: note.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async mentionHook(msg: Message) {
|
|
||||||
if (!msg.or(['/poll']) || msg.user.username !== config.master) {
|
|
||||||
return false;
|
|
||||||
} else {
|
|
||||||
this.log('Manualy poll requested');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.post();
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async timeoutCallback({ title, noteId }) {
|
|
||||||
const note: Note = await this.ai.api('notes/show', { noteId });
|
|
||||||
|
|
||||||
const choices = note.poll!.choices;
|
|
||||||
|
|
||||||
let mostVotedChoice;
|
|
||||||
|
|
||||||
for (const choice of choices) {
|
|
||||||
if (mostVotedChoice == null) {
|
|
||||||
mostVotedChoice = choice;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (choice.votes > mostVotedChoice.votes) {
|
|
||||||
mostVotedChoice = choice;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const mostVotedChoices = choices.filter(choice => choice.votes === mostVotedChoice.votes);
|
|
||||||
|
|
||||||
if (mostVotedChoice.votes === 0) {
|
|
||||||
this.ai.post({ // TODO: Extract serif
|
|
||||||
text: '投票はありませんでした',
|
|
||||||
renoteId: noteId,
|
|
||||||
});
|
|
||||||
} else if (mostVotedChoices.length === 1) {
|
|
||||||
this.ai.post({ // TODO: Extract serif
|
|
||||||
cw: `${title}アンケートの結果発表です!`,
|
|
||||||
text: `結果は${mostVotedChoice.votes}票の「${mostVotedChoice.text}」でした!`,
|
|
||||||
renoteId: noteId,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const choices = mostVotedChoices.map(choice => `「${choice.text}」`).join('と');
|
|
||||||
this.ai.post({ // TODO: Extract serif
|
|
||||||
cw: `${title}アンケートの結果発表です!`,
|
|
||||||
text: `結果は${mostVotedChoice.votes}票の${choices}でした!`,
|
|
||||||
renoteId: noteId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,451 +0,0 @@
|
|||||||
/**
|
|
||||||
* -AI-
|
|
||||||
* Botのバックエンド(思考を担当)
|
|
||||||
*
|
|
||||||
* 対話と思考を同じプロセスで行うと、思考時間が長引いたときにストリームから
|
|
||||||
* 切断されてしまうので、別々のプロセスで行うようにします
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'module-alias/register';
|
|
||||||
|
|
||||||
import * as request from 'request-promise-native';
|
|
||||||
import Reversi, { Color } from 'misskey-reversi';
|
|
||||||
import config from '@/config';
|
|
||||||
import serifs from '@/serifs';
|
|
||||||
import { User } from '@/misskey/user';
|
|
||||||
|
|
||||||
function getUserName(user) {
|
|
||||||
return user.name || user.username;
|
|
||||||
}
|
|
||||||
|
|
||||||
const titles = [
|
|
||||||
'さん', 'サン', 'サン', '㌠',
|
|
||||||
'ちゃん', 'チャン', 'チャン',
|
|
||||||
'君', 'くん', 'クン', 'クン',
|
|
||||||
'先生', 'せんせい', 'センセイ', 'センセイ'
|
|
||||||
];
|
|
||||||
|
|
||||||
class Session {
|
|
||||||
private account: User;
|
|
||||||
private game: any;
|
|
||||||
private form: any;
|
|
||||||
private o: Reversi;
|
|
||||||
private botColor: Color;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 隅周辺のインデックスリスト(静的評価に利用)
|
|
||||||
*/
|
|
||||||
private sumiNearIndexes: number[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 隅のインデックスリスト(静的評価に利用)
|
|
||||||
*/
|
|
||||||
private sumiIndexes: number[] = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 最大のターン数
|
|
||||||
*/
|
|
||||||
private maxTurn;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 現在のターン数
|
|
||||||
*/
|
|
||||||
private currentTurn = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 対局が開始したことを知らせた投稿
|
|
||||||
*/
|
|
||||||
private startedNote: any = null;
|
|
||||||
|
|
||||||
private get user(): User {
|
|
||||||
return this.game.user1Id == this.account.id ? this.game.user2 : this.game.user1;
|
|
||||||
}
|
|
||||||
|
|
||||||
private get userName(): string {
|
|
||||||
const name = getUserName(this.user);
|
|
||||||
return `?[${name}](${config.host}/@${this.user.username})${titles.some(x => name.endsWith(x)) ? '' : 'さん'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private get strength(): number {
|
|
||||||
return this.form.find(i => i.id == 'strength').value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private get isSettai(): boolean {
|
|
||||||
return this.strength === 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private get allowPost(): boolean {
|
|
||||||
return this.form.find(i => i.id == 'publish').value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private get url(): string {
|
|
||||||
return `${config.host}/games/reversi/${this.game.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
process.on('message', this.onMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
private onMessage = async (msg: any) => {
|
|
||||||
switch (msg.type) {
|
|
||||||
case '_init_': this.onInit(msg.body); break;
|
|
||||||
case 'updateForm': this.onUpdateForn(msg.body); break;
|
|
||||||
case 'started': this.onStarted(msg.body); break;
|
|
||||||
case 'ended': this.onEnded(msg.body); break;
|
|
||||||
case 'set': this.onSet(msg.body); break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 親プロセスからデータをもらう
|
|
||||||
private onInit = (msg: any) => {
|
|
||||||
this.game = msg.game;
|
|
||||||
this.form = msg.form;
|
|
||||||
this.account = msg.account;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* フォームが更新されたとき
|
|
||||||
*/
|
|
||||||
private onUpdateForn = (msg: any) => {
|
|
||||||
this.form.find(i => i.id == msg.id).value = msg.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 対局が始まったとき
|
|
||||||
*/
|
|
||||||
private onStarted = (msg: any) => {
|
|
||||||
this.game = msg;
|
|
||||||
|
|
||||||
// TLに投稿する
|
|
||||||
this.postGameStarted().then(note => {
|
|
||||||
this.startedNote = note;
|
|
||||||
});
|
|
||||||
|
|
||||||
// リバーシエンジン初期化
|
|
||||||
this.o = new Reversi(this.game.map, {
|
|
||||||
isLlotheo: this.game.isLlotheo,
|
|
||||||
canPutEverywhere: this.game.canPutEverywhere,
|
|
||||||
loopedBoard: this.game.loopedBoard
|
|
||||||
});
|
|
||||||
|
|
||||||
this.maxTurn = this.o.map.filter(p => p === 'empty').length - this.o.board.filter(x => x != null).length;
|
|
||||||
|
|
||||||
//#region 隅の位置計算など
|
|
||||||
|
|
||||||
//#region 隅
|
|
||||||
this.o.map.forEach((pix, i) => {
|
|
||||||
if (pix == 'null') return;
|
|
||||||
|
|
||||||
const [x, y] = this.o.transformPosToXy(i);
|
|
||||||
const get = (x, y) => {
|
|
||||||
if (x < 0 || y < 0 || x >= this.o.mapWidth || y >= this.o.mapHeight) return 'null';
|
|
||||||
return this.o.mapDataGet(this.o.transformXyToPos(x, y));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isNotSumi = (
|
|
||||||
// -
|
|
||||||
// +
|
|
||||||
// -
|
|
||||||
(get(x - 1, y - 1) == 'empty' && get(x + 1, y + 1) == 'empty') ||
|
|
||||||
|
|
||||||
// -
|
|
||||||
// +
|
|
||||||
// -
|
|
||||||
(get(x, y - 1) == 'empty' && get(x, y + 1) == 'empty') ||
|
|
||||||
|
|
||||||
// -
|
|
||||||
// +
|
|
||||||
// -
|
|
||||||
(get(x + 1, y - 1) == 'empty' && get(x - 1, y + 1) == 'empty') ||
|
|
||||||
|
|
||||||
//
|
|
||||||
// -+-
|
|
||||||
//
|
|
||||||
(get(x - 1, y) == 'empty' && get(x + 1, y) == 'empty')
|
|
||||||
)
|
|
||||||
|
|
||||||
const isSumi = !isNotSumi;
|
|
||||||
|
|
||||||
if (isSumi) this.sumiIndexes.push(i);
|
|
||||||
});
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
//#region 隅の隣
|
|
||||||
this.o.map.forEach((pix, i) => {
|
|
||||||
if (pix == 'null') return;
|
|
||||||
if (this.sumiIndexes.includes(i)) return;
|
|
||||||
|
|
||||||
const [x, y] = this.o.transformPosToXy(i);
|
|
||||||
|
|
||||||
const check = (x, y) => {
|
|
||||||
if (x < 0 || y < 0 || x >= this.o.mapWidth || y >= this.o.mapHeight) return 0;
|
|
||||||
return this.sumiIndexes.includes(this.o.transformXyToPos(x, y));
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSumiNear = (
|
|
||||||
check(x - 1, y - 1) || // 左上
|
|
||||||
check(x , y - 1) || // 上
|
|
||||||
check(x + 1, y - 1) || // 右上
|
|
||||||
check(x + 1, y ) || // 右
|
|
||||||
check(x + 1, y + 1) || // 右下
|
|
||||||
check(x , y + 1) || // 下
|
|
||||||
check(x - 1, y + 1) || // 左下
|
|
||||||
check(x - 1, y ) // 左
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isSumiNear) this.sumiNearIndexes.push(i);
|
|
||||||
});
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
this.botColor = this.game.user1Id == this.account.id && this.game.black == 1 || this.game.user2Id == this.account.id && this.game.black == 2;
|
|
||||||
|
|
||||||
if (this.botColor) {
|
|
||||||
this.think();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 対局が終わったとき
|
|
||||||
*/
|
|
||||||
private onEnded = async (msg: any) => {
|
|
||||||
// ストリームから切断
|
|
||||||
process.send!({
|
|
||||||
type: 'ended'
|
|
||||||
});
|
|
||||||
|
|
||||||
let text: string;
|
|
||||||
|
|
||||||
if (msg.game.surrendered) {
|
|
||||||
if (this.isSettai) {
|
|
||||||
text = serifs.reversi.settaiButYouSurrendered(this.userName);
|
|
||||||
} else {
|
|
||||||
text = serifs.reversi.youSurrendered(this.userName);
|
|
||||||
}
|
|
||||||
} else if (msg.winnerId) {
|
|
||||||
if (msg.winnerId == this.account.id) {
|
|
||||||
if (this.isSettai) {
|
|
||||||
text = serifs.reversi.iWonButSettai(this.userName);
|
|
||||||
} else {
|
|
||||||
text = serifs.reversi.iWon(this.userName);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (this.isSettai) {
|
|
||||||
text = serifs.reversi.iLoseButSettai(this.userName);
|
|
||||||
} else {
|
|
||||||
text = serifs.reversi.iLose(this.userName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (this.isSettai) {
|
|
||||||
text = serifs.reversi.drawnSettai(this.userName);
|
|
||||||
} else {
|
|
||||||
text = serifs.reversi.drawn(this.userName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.post(text, this.startedNote);
|
|
||||||
|
|
||||||
process.exit();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 打たれたとき
|
|
||||||
*/
|
|
||||||
private onSet = (msg: any) => {
|
|
||||||
this.o.put(msg.color, msg.pos);
|
|
||||||
this.currentTurn++;
|
|
||||||
|
|
||||||
if (msg.next === this.botColor) {
|
|
||||||
this.think();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Botにとってある局面がどれだけ有利か静的に評価する
|
|
||||||
* static(静的)というのは、先読みはせずに盤面の状態のみで評価するということ。
|
|
||||||
* TODO: 接待時はまるっと処理の中身を変え、とにかく相手が隅を取っていること優先な評価にする
|
|
||||||
*/
|
|
||||||
private staticEval = () => {
|
|
||||||
let score = this.o.canPutSomewhere(this.botColor).length;
|
|
||||||
|
|
||||||
for (const index of this.sumiIndexes) {
|
|
||||||
const stone = this.o.board[index];
|
|
||||||
|
|
||||||
if (stone === this.botColor) {
|
|
||||||
score += 1000; // 自分が隅を取っていたらスコアプラス
|
|
||||||
} else if (stone !== null) {
|
|
||||||
score -= 1000; // 相手が隅を取っていたらスコアマイナス
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: ここに (隅以外の確定石の数 * 100) をスコアに加算する処理を入れる
|
|
||||||
|
|
||||||
for (const index of this.sumiNearIndexes) {
|
|
||||||
const stone = this.o.board[index];
|
|
||||||
|
|
||||||
if (stone === this.botColor) {
|
|
||||||
score -= 10; // 自分が隅の周辺を取っていたらスコアマイナス(危険なので)
|
|
||||||
} else if (stone !== null) {
|
|
||||||
score += 10; // 相手が隅の周辺を取っていたらスコアプラス
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ロセオならスコアを反転
|
|
||||||
if (this.game.isLlotheo) score = -score;
|
|
||||||
|
|
||||||
// 接待ならスコアを反転
|
|
||||||
if (this.isSettai) score = -score;
|
|
||||||
|
|
||||||
return score;
|
|
||||||
}
|
|
||||||
|
|
||||||
private think = () => {
|
|
||||||
console.log(`(${this.currentTurn}/${this.maxTurn}) Thinking...`);
|
|
||||||
console.time('think');
|
|
||||||
|
|
||||||
// 接待モードのときは、全力(5手先読みくらい)で負けるようにする
|
|
||||||
// TODO: 接待のときは、どちらかというと「自分が不利になる手を選ぶ」というよりは、「相手に角を取らせられる手を選ぶ」ように思考する
|
|
||||||
// 自分が不利になる手を選ぶというのは、換言すれば自分が打てる箇所を減らすことになるので、
|
|
||||||
// 自分が打てる箇所が少ないと結果的に思考の選択肢が狭まり、対局をコントロールするのが難しくなるジレンマのようなものがある。
|
|
||||||
// つまり「相手を勝たせる」という意味での正しい接待は、「ゲーム序盤・中盤までは(通常通り)自分の有利になる手を打ち、終盤になってから相手が勝つように打つ」こと。
|
|
||||||
// とはいえ藍に求められているのは、そういった「本物の」接待ではなく、単に「角を取らせてくれる」接待だと思われるので、
|
|
||||||
// 静的評価で「角に相手の石があるかどうか(と、ゲームが終わったときは相手が勝っているかどうか)」を考慮するようにすれば良いかもしれない。
|
|
||||||
const maxDepth = this.isSettai ? 5 : this.strength;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* αβ法での探索
|
|
||||||
*/
|
|
||||||
const dive = (pos: number, alpha = -Infinity, beta = Infinity, depth = 0): number => {
|
|
||||||
// 試し打ち
|
|
||||||
this.o.put(this.o.turn, pos);
|
|
||||||
|
|
||||||
const isBotTurn = this.o.turn === this.botColor;
|
|
||||||
|
|
||||||
// 勝った
|
|
||||||
if (this.o.turn === null) {
|
|
||||||
const winner = this.o.winner;
|
|
||||||
|
|
||||||
// 勝つことによる基本スコア
|
|
||||||
const base = 10000;
|
|
||||||
|
|
||||||
let score;
|
|
||||||
|
|
||||||
if (this.game.isLlotheo) {
|
|
||||||
// 勝ちは勝ちでも、より自分の石を少なくした方が美しい勝ちだと判定する
|
|
||||||
score = this.o.winner ? base - (this.o.blackCount * 100) : base - (this.o.whiteCount * 100);
|
|
||||||
} else {
|
|
||||||
// 勝ちは勝ちでも、より相手の石を少なくした方が美しい勝ちだと判定する
|
|
||||||
score = this.o.winner ? base + (this.o.blackCount * 100) : base + (this.o.whiteCount * 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 巻き戻し
|
|
||||||
this.o.undo();
|
|
||||||
|
|
||||||
// 接待なら自分が負けた方が高スコア
|
|
||||||
return this.isSettai
|
|
||||||
? winner !== this.botColor ? score : -score
|
|
||||||
: winner === this.botColor ? score : -score;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (depth === maxDepth) {
|
|
||||||
// 静的に評価
|
|
||||||
const score = this.staticEval();
|
|
||||||
|
|
||||||
// 巻き戻し
|
|
||||||
this.o.undo();
|
|
||||||
|
|
||||||
return score;
|
|
||||||
} else {
|
|
||||||
const cans = this.o.canPutSomewhere(this.o.turn);
|
|
||||||
|
|
||||||
let value = isBotTurn ? -Infinity : Infinity;
|
|
||||||
let a = alpha;
|
|
||||||
let b = beta;
|
|
||||||
|
|
||||||
// TODO: 残りターン数というよりも「空いているマスが12以下」の場合に完全読みさせる
|
|
||||||
const nextDepth = (this.strength >= 4) && ((this.maxTurn - this.currentTurn) <= 12) ? Infinity : depth + 1;
|
|
||||||
|
|
||||||
// 次のターンのプレイヤーにとって最も良い手を取得
|
|
||||||
// TODO: cansをまず浅く読んで(または価値マップを利用して)から有益そうな手から順に並べ替え、効率よく枝刈りできるようにする
|
|
||||||
for (const p of cans) {
|
|
||||||
if (isBotTurn) {
|
|
||||||
const score = dive(p, a, beta, nextDepth);
|
|
||||||
value = Math.max(value, score);
|
|
||||||
a = Math.max(a, value);
|
|
||||||
if (value >= beta) break;
|
|
||||||
} else {
|
|
||||||
const score = dive(p, alpha, b, nextDepth);
|
|
||||||
value = Math.min(value, score);
|
|
||||||
b = Math.min(b, value);
|
|
||||||
if (value <= alpha) break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 巻き戻し
|
|
||||||
this.o.undo();
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cans = this.o.canPutSomewhere(this.botColor);
|
|
||||||
const scores = cans.map(p => dive(p));
|
|
||||||
const pos = cans[scores.indexOf(Math.max(...scores))];
|
|
||||||
|
|
||||||
console.log('Thinked:', pos);
|
|
||||||
console.timeEnd('think');
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
process.send!({
|
|
||||||
type: 'put',
|
|
||||||
pos
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 対局が始まったことをMisskeyに投稿します
|
|
||||||
*/
|
|
||||||
private postGameStarted = async () => {
|
|
||||||
const text = this.isSettai
|
|
||||||
? serifs.reversi.startedSettai(this.userName)
|
|
||||||
: serifs.reversi.started(this.userName, this.strength.toString());
|
|
||||||
|
|
||||||
return await this.post(`${text}\n→[観戦する](${this.url})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Misskeyに投稿します
|
|
||||||
* @param text 投稿内容
|
|
||||||
*/
|
|
||||||
private post = async (text: string, renote?: any) => {
|
|
||||||
if (this.allowPost) {
|
|
||||||
const body = {
|
|
||||||
i: config.i,
|
|
||||||
text: text,
|
|
||||||
visibility: 'home'
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
if (renote) {
|
|
||||||
body.renoteId = renote.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await request.post(`${config.host}/api/notes/create`, {
|
|
||||||
json: body
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.createdNote;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
new Session();
|
|
@ -1,180 +0,0 @@
|
|||||||
import * as childProcess from 'child_process';
|
|
||||||
import autobind from 'autobind-decorator';
|
|
||||||
import Module from '@/module';
|
|
||||||
import serifs from '@/serifs';
|
|
||||||
import config from '@/config';
|
|
||||||
import Message from '@/message';
|
|
||||||
import Friend from '@/friend';
|
|
||||||
import getDate from '@/utils/get-date';
|
|
||||||
|
|
||||||
export default class extends Module {
|
|
||||||
public readonly name = 'reversi';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* リバーシストリーム
|
|
||||||
*/
|
|
||||||
private reversiConnection?: any;
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public install() {
|
|
||||||
if (!config.reversiEnabled) return {};
|
|
||||||
|
|
||||||
this.reversiConnection = this.ai.connection.useSharedConnection('gamesReversi');
|
|
||||||
|
|
||||||
// 招待されたとき
|
|
||||||
this.reversiConnection.on('invited', msg => this.onReversiInviteMe(msg.parent));
|
|
||||||
|
|
||||||
// マッチしたとき
|
|
||||||
this.reversiConnection.on('matched', msg => this.onReversiGameStart(msg));
|
|
||||||
|
|
||||||
if (config.reversiEnabled) {
|
|
||||||
const mainStream = this.ai.connection.useSharedConnection('main');
|
|
||||||
mainStream.on('pageEvent', msg => {
|
|
||||||
if (msg.event === 'inviteReversi') {
|
|
||||||
this.ai.api('games/reversi/match', {
|
|
||||||
userId: msg.user.id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
mentionHook: this.mentionHook
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async mentionHook(msg: Message) {
|
|
||||||
if (msg.includes(['リバーシ', 'オセロ', 'reversi', 'othello'])) {
|
|
||||||
if (config.reversiEnabled) {
|
|
||||||
msg.reply(serifs.reversi.ok);
|
|
||||||
|
|
||||||
this.ai.api('games/reversi/match', {
|
|
||||||
userId: msg.userId
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
msg.reply(serifs.reversi.decline);
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async onReversiInviteMe(inviter: any) {
|
|
||||||
this.log(`Someone invited me: @${inviter.username}`);
|
|
||||||
|
|
||||||
if (config.reversiEnabled) {
|
|
||||||
// 承認
|
|
||||||
const game = await this.ai.api('games/reversi/match', {
|
|
||||||
userId: inviter.id
|
|
||||||
});
|
|
||||||
|
|
||||||
this.onReversiGameStart(game);
|
|
||||||
} else {
|
|
||||||
// todo (リバーシできない旨をメッセージで伝えるなど)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private onReversiGameStart(game: any) {
|
|
||||||
this.log('enter reversi game room');
|
|
||||||
|
|
||||||
// ゲームストリームに接続
|
|
||||||
const gw = this.ai.connection.connectToChannel('gamesReversiGame', {
|
|
||||||
gameId: game.id
|
|
||||||
});
|
|
||||||
|
|
||||||
// フォーム
|
|
||||||
const form = [{
|
|
||||||
id: 'publish',
|
|
||||||
type: 'switch',
|
|
||||||
label: '藍が対局情報を投稿するのを許可',
|
|
||||||
value: true
|
|
||||||
}, {
|
|
||||||
id: 'strength',
|
|
||||||
type: 'radio',
|
|
||||||
label: '強さ',
|
|
||||||
value: 3,
|
|
||||||
items: [{
|
|
||||||
label: '接待',
|
|
||||||
value: 0
|
|
||||||
}, {
|
|
||||||
label: '弱',
|
|
||||||
value: 2
|
|
||||||
}, {
|
|
||||||
label: '中',
|
|
||||||
value: 3
|
|
||||||
}, {
|
|
||||||
label: '強',
|
|
||||||
value: 4
|
|
||||||
}, {
|
|
||||||
label: '最強',
|
|
||||||
value: 5
|
|
||||||
}]
|
|
||||||
}];
|
|
||||||
|
|
||||||
//#region バックエンドプロセス開始
|
|
||||||
const ai = childProcess.fork(__dirname + '/back.js');
|
|
||||||
|
|
||||||
// バックエンドプロセスに情報を渡す
|
|
||||||
ai.send({
|
|
||||||
type: '_init_',
|
|
||||||
body: {
|
|
||||||
game: game,
|
|
||||||
form: form,
|
|
||||||
account: this.ai.account
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
ai.on('message', (msg: Record<string, any>) => {
|
|
||||||
if (msg.type == 'put') {
|
|
||||||
gw.send('set', {
|
|
||||||
pos: msg.pos
|
|
||||||
});
|
|
||||||
} else if (msg.type == 'ended') {
|
|
||||||
gw.dispose();
|
|
||||||
|
|
||||||
this.onGameEnded(game);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ゲームストリームから情報が流れてきたらそのままバックエンドプロセスに伝える
|
|
||||||
gw.addListener('*', message => {
|
|
||||||
ai.send(message);
|
|
||||||
});
|
|
||||||
//#endregion
|
|
||||||
|
|
||||||
// フォーム初期化
|
|
||||||
setTimeout(() => {
|
|
||||||
gw.send('initForm', form);
|
|
||||||
}, 1000);
|
|
||||||
|
|
||||||
// どんな設定内容の対局でも受け入れる
|
|
||||||
setTimeout(() => {
|
|
||||||
gw.send('accept', {});
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private onGameEnded(game: any) {
|
|
||||||
const user = game.user1Id == this.ai.account.id ? game.user2 : game.user1;
|
|
||||||
|
|
||||||
//#region 1日に1回だけ親愛度を上げる
|
|
||||||
const today = getDate();
|
|
||||||
|
|
||||||
const friend = new Friend(this.ai, { user: user });
|
|
||||||
|
|
||||||
const data = friend.getPerModulesData(this);
|
|
||||||
|
|
||||||
if (data.lastPlayedAt != today) {
|
|
||||||
data.lastPlayedAt = today;
|
|
||||||
friend.setPerModulesData(this, data);
|
|
||||||
|
|
||||||
friend.incLove();
|
|
||||||
}
|
|
||||||
//#endregion
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,33 +0,0 @@
|
|||||||
import autobind from 'autobind-decorator';
|
|
||||||
import Module from '@/module';
|
|
||||||
|
|
||||||
export default class extends Module {
|
|
||||||
public readonly name = 'welcome';
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public install() {
|
|
||||||
const tl = this.ai.connection.useSharedConnection('localTimeline');
|
|
||||||
|
|
||||||
tl.on('note', this.onLocalNote);
|
|
||||||
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private onLocalNote(note: any) {
|
|
||||||
if (note.isFirstNote) {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.ai.api('notes/create', {
|
|
||||||
renoteId: note.id
|
|
||||||
});
|
|
||||||
}, 3000);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
this.ai.api('notes/reactions/create', {
|
|
||||||
noteId: note.id,
|
|
||||||
reaction: 'congrats'
|
|
||||||
});
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
import { katakanaToHiragana, hankakuToZenkaku } from './japanese';
|
|
||||||
|
|
||||||
export default function(text: string, words: string[]): boolean {
|
|
||||||
if (text == null) return false;
|
|
||||||
|
|
||||||
text = katakanaToHiragana(hankakuToZenkaku(text)).toLowerCase();
|
|
||||||
words = words.map(word => katakanaToHiragana(word).toLowerCase());
|
|
||||||
|
|
||||||
return words.some(word => text.includes(word));
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
export const account = {
|
|
||||||
id: '0',
|
|
||||||
name: '藍',
|
|
||||||
username: 'ai',
|
|
||||||
host: null,
|
|
||||||
isBot: true,
|
|
||||||
};
|
|
@ -1,67 +0,0 @@
|
|||||||
import * as http from 'http';
|
|
||||||
import * as Koa from 'koa';
|
|
||||||
import * as websocket from 'websocket';
|
|
||||||
|
|
||||||
export class Misskey {
|
|
||||||
private server: http.Server;
|
|
||||||
private streaming: websocket.connection;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
const app = new Koa();
|
|
||||||
|
|
||||||
this.server = http.createServer(app.callback());
|
|
||||||
|
|
||||||
const ws = new websocket.server({
|
|
||||||
httpServer: this.server
|
|
||||||
});
|
|
||||||
|
|
||||||
ws.on('request', async (request) => {
|
|
||||||
const q = request.resourceURL.query as ParsedUrlQuery;
|
|
||||||
|
|
||||||
this.streaming = request.accept();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.server.listen(3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
public waitForStreamingMessage(handler) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const onMessage = (data: websocket.IMessage) => {
|
|
||||||
if (data.utf8Data == null) return;
|
|
||||||
const message = JSON.parse(data.utf8Data);
|
|
||||||
const result = handler(message);
|
|
||||||
if (result) {
|
|
||||||
this.streaming.off('message', onMessage);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.streaming.on('message', onMessage);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async waitForMainChannelConnected() {
|
|
||||||
await this.waitForStreamingMessage(message => {
|
|
||||||
const { type, body } = message;
|
|
||||||
if (type === 'connect') {
|
|
||||||
const { channel, id, params, pong } = body;
|
|
||||||
|
|
||||||
if (channel !== 'main') return;
|
|
||||||
|
|
||||||
if (pong) {
|
|
||||||
this.sendStreamingMessage('connected', {
|
|
||||||
id: id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public sendStreamingMessage(type: string, payload: any) {
|
|
||||||
this.streaming.send(JSON.stringify({
|
|
||||||
type: type,
|
|
||||||
body: payload
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,17 +0,0 @@
|
|||||||
import * as websocket from 'websocket';
|
|
||||||
|
|
||||||
export class StreamingApi {
|
|
||||||
private ws: WS;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
this.ws = new WS('ws://localhost/streaming');
|
|
||||||
}
|
|
||||||
|
|
||||||
public async waitForMainChannelConnected() {
|
|
||||||
await expect(this.ws).toReceiveMessage("hello");
|
|
||||||
}
|
|
||||||
|
|
||||||
public send(message) {
|
|
||||||
this.ws.send(JSON.stringify(message));
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,26 +0,0 @@
|
|||||||
import autobind from 'autobind-decorator';
|
|
||||||
import Module from '@/module';
|
|
||||||
import Message from '@/message';
|
|
||||||
|
|
||||||
export default class extends Module {
|
|
||||||
public readonly name = 'test';
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
public install() {
|
|
||||||
return {
|
|
||||||
mentionHook: this.mentionHook
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
@autobind
|
|
||||||
private async mentionHook(msg: Message) {
|
|
||||||
if (msg.text && msg.text.includes('ping')) {
|
|
||||||
msg.reply('PONG!', {
|
|
||||||
immediate: true
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
20
test/core.ts
20
test/core.ts
@ -1,20 +0,0 @@
|
|||||||
import 藍 from '@/ai';
|
|
||||||
import { account } from '#/__mocks__/account';
|
|
||||||
import TestModule from '#/__modules__/test';
|
|
||||||
import { StreamingApi } from '#/__mocks__/ws';
|
|
||||||
|
|
||||||
process.env.NODE_ENV = 'test';
|
|
||||||
|
|
||||||
let ai: 藍;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
ai = new 藍(account, [
|
|
||||||
new TestModule(),
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('mention hook', async () => {
|
|
||||||
const streaming = new StreamingApi();
|
|
||||||
|
|
||||||
|
|
||||||
});
|
|
@ -1,15 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "../tsconfig.json",
|
|
||||||
"compilerOptions": {
|
|
||||||
"baseUrl": ".",
|
|
||||||
"rootDir": "../",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["../src/*"],
|
|
||||||
"#/*": ["./*"]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"compileOnSave": false,
|
|
||||||
"include": [
|
|
||||||
"**/*.ts"
|
|
||||||
]
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user