Commit 43274a7f authored by nanahira's avatar nanahira

support yaml deck

parent f2131f12
This diff is collapsed.
...@@ -44,6 +44,7 @@ ...@@ -44,6 +44,7 @@
"testEnvironment": "node" "testEnvironment": "node"
}, },
"dependencies": { "dependencies": {
"js-yaml": "^4.1.0",
"koishi-thirdeye": "^11.0.9", "koishi-thirdeye": "^11.0.9",
"leven": "^3.1.0", "leven": "^3.1.0",
"load-json-file": "^6.2.0", "load-json-file": "^6.2.0",
...@@ -58,6 +59,7 @@ ...@@ -58,6 +59,7 @@
"@koishijs/plugin-database-memory": "^1.4.1", "@koishijs/plugin-database-memory": "^1.4.1",
"@koishijs/plugin-sandbox": "^2.0.1", "@koishijs/plugin-sandbox": "^2.0.1",
"@types/jest": "^27.4.1", "@types/jest": "^27.4.1",
"@types/js-yaml": "^4.0.5",
"@types/lodash": "^4.14.179", "@types/lodash": "^4.14.179",
"@types/node": "^17.0.21", "@types/node": "^17.0.21",
"@typescript-eslint/eslint-plugin": "^4.33.0", "@typescript-eslint/eslint-plugin": "^4.33.0",
......
...@@ -21,13 +21,15 @@ export class DrawPluginConfig { ...@@ -21,13 +21,15 @@ export class DrawPluginConfig {
}) })
maxDepth: number; maxDepth: number;
async loadFileList() { async loadFileList(...suffixes: string[]) {
return _.flatten( return _.flatten(
await Promise.all( await Promise.all(
this.deckPaths.map(async (deckPath) => { this.deckPaths.map(async (deckPath) => {
const files = await fs.promises.readdir(deckPath); const files = await fs.promises.readdir(deckPath);
return files return files
.filter((file) => file.endsWith('.json')) .filter((file) =>
suffixes.some((suffix) => file.endsWith('.' + suffix)),
)
.map((file) => path.join(deckPath, file)); .map((file) => path.join(deckPath, file));
}), }),
), ),
......
import { Logger, Random } from 'koishi';
import _ from 'lodash';
import { OneDice } from 'onedice';
export type Decks = Record<string, string[]>;
export class Drawer {
constructor(
public decks: Decks,
private logger: Logger,
private maxDepth: number,
) {}
protected parseEntry(name: string, entry: string, depth = 1): string {
let result = entry.replace(/\[([^\]]+)\]/g, (dicePattern) =>
new OneDice().calculate(dicePattern.slice(1, -1)).toString(),
);
if (depth > this.maxDepth) {
this.logger.warn(`Max depth ${this.maxDepth} reached in deck ${name}.`);
return entry;
}
const usedUniqueIndex = new Map<string, Set<number>>();
result = result
.replace(/\{[^%\}]+\}/g, (refPattern) => {
let deckName = refPattern.slice(1, -1);
if (deckName.startsWith('$')) {
deckName = deckName.slice(1);
}
return this.drawFromDeck(deckName, depth + 1, name) || refPattern;
})
.replace(/\{%[^%\}]+\}/g, (uniqRefPattern) => {
const refName = uniqRefPattern.slice(2, -1);
let indexArray = usedUniqueIndex.get(refName);
if (!indexArray) {
indexArray = new Set();
usedUniqueIndex.set(refName, indexArray);
}
const deck = this.decks[refName];
if (!deck) {
this.logger.warn(
`Referenced deck ${refName} not found in deck ${name}.`,
);
return uniqRefPattern;
}
const availableIndexes = _.range(deck.length).filter(
(index) => !indexArray.has(index),
);
if (availableIndexes.length === 0) {
this.logger.warn(
`No more unique entries left for ${refName} in deck ${name}.`,
);
return uniqRefPattern;
}
const index = Random.pick(availableIndexes);
indexArray.add(index);
const entry = deck[index];
return this.parseEntry(refName, entry, depth + 1);
});
return result;
}
drawFromDeck(name: string, depth = 1, referencedDeck?: string): string {
const deck = this.decks[name];
if (!deck) {
this.logger.warn(
`${referencedDeck ? 'Referenced deck' : 'Deck'} ${name} not found${
referencedDeck ? `in deck ${referencedDeck}` : ''
}.`,
);
return null;
}
const entry = Random.pick(deck);
return this.parseEntry(name, entry, depth);
}
}
...@@ -14,17 +14,18 @@ import { ...@@ -14,17 +14,18 @@ import {
PutRenderer, PutRenderer,
StarterPlugin, StarterPlugin,
} from 'koishi-thirdeye'; } from 'koishi-thirdeye';
import { Logger, Random } from 'koishi'; import { Logger } from 'koishi';
import path from 'path'; import path from 'path';
import loadJsonFile from 'load-json-file'; import loadJsonFile from 'load-json-file';
import _ from 'lodash'; import _ from 'lodash';
import * as localeZh from './locales/zh'; import * as localeZh from './locales/zh';
import * as localeEn from './locales/en'; import * as localeEn from './locales/en';
import leven from 'leven'; import leven from 'leven';
import { OneDice } from 'onedice'; import { Decks, Drawer } from './drawer';
import { YamlDrawer, YamlStruct } from './yaml-plugin';
export * from './config'; export * from './config';
import yaml from 'js-yaml';
type Decks = Record<string, string[]>; import fs from 'fs';
@DefinePlugin() @DefinePlugin()
export default class DrawPlugin export default class DrawPlugin
...@@ -34,121 +35,110 @@ export default class DrawPlugin ...@@ -34,121 +35,110 @@ export default class DrawPlugin
@InjectLogger() @InjectLogger()
private logger: Logger; private logger: Logger;
deckFiles = new Map<string, string[]>(); jsonDeckFiles = new Map<string, string[]>();
decks: Decks = {}; drawer: Drawer;
yamlDecks = new Map<string, YamlDrawer>();
yamlDeckFiles = new Map<string, YamlDrawer>();
onApply() {
this.drawer = new Drawer({}, this.logger, this.config.maxDepth);
}
async loadDeck(file: string) { async loadJsonDeck(file: string) {
const filename = path.basename(file); const filename = path.basename(file);
if (this.deckFiles.has(filename)) return; if (this.jsonDeckFiles.has(filename)) return;
try { try {
const content: Decks = await loadJsonFile(file); const content: Decks = await loadJsonFile(file);
const deckTitles = Object.keys(content); const deckTitles = Object.keys(content);
this.deckFiles.set(filename, deckTitles); this.jsonDeckFiles.set(filename, deckTitles);
for (const key of deckTitles) { for (const key of deckTitles) {
if (this.decks[key]) { if (this.drawer.decks[key]) {
this.logger.warn(`Duplicate deck ${key} in ${file}`); this.logger.warn(`Duplicate deck ${key} in ${file}`);
} }
this.decks[key] = content[key]; this.drawer.decks[key] = content[key];
} }
} catch (e) { } catch (e) {
this.logger.error(`Load deck file ${file} failed: ${e.message}`); this.logger.error(`Load deck file ${file} failed: ${e.message}`);
} }
} }
async loadDecks() { async loadYamlDeck(file: string) {
this.deckFiles.clear(); const filename = path.basename(file);
this.decks = {}; if (this.yamlDeckFiles.has(filename)) return;
const files = await this.config.loadFileList(); try {
await Promise.all(files.map((file) => this.loadDeck(file))); const content = yaml.load(
await fs.promises.readFile(file, 'utf8'),
) as YamlStruct;
if (!content.includes || !content.command) {
throw new Error('Invalid yaml deck');
}
const deck = new YamlDrawer(content, this.logger, this.config.maxDepth);
this.yamlDeckFiles.set(filename, deck);
this.yamlDecks.set(deck.meta.command, deck);
} catch (e) {
this.logger.error(`Load deck file ${file} failed: ${e.message}`);
}
}
async loadJsonDecks() {
this.jsonDeckFiles.clear();
this.drawer.decks = {};
const files = await this.config.loadFileList('json');
await Promise.all(files.map((file) => this.loadJsonDeck(file)));
const deckCount = _.sumBy( const deckCount = _.sumBy(
Array.from(this.deckFiles.values()), Array.from(this.jsonDeckFiles.values()),
(v) => v.length, (v) => v.length,
); );
const deckFileCount = this.deckFiles.size; const deckFileCount = this.jsonDeckFiles.size;
this.logger.info( this.logger.info(
`Loaded ${deckCount} decks from ${deckFileCount} deck files.`, `Loaded ${deckCount} JSON decks from ${deckFileCount} deck files.`,
); );
return { deckCount, deckFileCount }; return { deckCount, deckFileCount };
} }
parseEntry(name: string, entry: string, depth = 1): string { async loadYamlDecks() {
let result = entry.replace(/\[([^\]]+)\]/g, (dicePattern) => this.yamlDeckFiles.clear();
new OneDice().calculate(dicePattern.slice(1, -1)).toString(), this.yamlDecks.clear();
const files = await this.config.loadFileList('yaml', 'yml');
await Promise.all(files.map((file) => this.loadYamlDeck(file)));
const deckCount = this.yamlDecks.size;
const deckFileCount = deckCount;
this.logger.info(
`Loaded ${deckCount} YAML decks from ${deckFileCount} deck files.`,
); );
if (depth > this.config.maxDepth) { return { deckCount, deckFileCount };
this.logger.warn(
`Max depth ${this.config.maxDepth} reached in deck ${name}.`,
);
return entry;
}
const usedUniqueIndex = new Map<string, Set<number>>();
result = result
.replace(
/\{[^%\}]+\}/g,
(refPattern) =>
this.drawFromDeck(refPattern.slice(1, -1), depth + 1, name) ||
refPattern,
)
.replace(/\{%[^%\}]+\}/g, (uniqRefPattern) => {
const refName = uniqRefPattern.slice(2, -1);
let indexArray = usedUniqueIndex.get(refName);
if (!indexArray) {
indexArray = new Set();
usedUniqueIndex.set(refName, indexArray);
}
const deck = this.decks[refName];
if (!deck) {
this.logger.warn(
`Referenced deck ${refName} not found in deck ${name}.`,
);
return uniqRefPattern;
}
const availableIndexes = _.range(deck.length).filter(
(index) => !indexArray.has(index),
);
if (availableIndexes.length === 0) {
this.logger.warn(
`No more unique entries left for ${refName} in deck ${name}.`,
);
return uniqRefPattern;
}
const index = Random.pick(availableIndexes);
indexArray.add(index);
const entry = deck[index];
return this.parseEntry(refName, entry, depth + 1);
});
return result;
} }
drawFromDeck(name: string, depth = 1, referencedDeck?: string): string { async loadDecks() {
const deck = this.decks[name]; const results = await Promise.all([
if (!deck) { this.loadJsonDecks(),
this.logger.warn( this.loadYamlDecks(),
`${referencedDeck ? 'Referenced deck' : 'Deck'} ${name} not found${ ]);
referencedDeck ? `in deck ${referencedDeck}` : '' return {
}.`, deckCount: results[0].deckCount + results[1].deckCount,
); deckFileCount: results[0].deckFileCount + results[1].deckFileCount,
return null; };
}
const entry = Random.pick(deck);
return this.parseEntry(name, entry, depth);
} }
async onConnect() { async onConnect() {
await this.loadDecks(); await this.loadDecks();
} }
@UseCommand('draw <name:text>', { checkArgCount: true }) @UseCommand('draw <name> [param]', { checkArgCount: true })
@CommandLocale('zh', localeZh.commands.draw) @CommandLocale('zh', localeZh.commands.draw)
@CommandLocale('en', localeEn.commands.draw) @CommandLocale('en', localeEn.commands.draw)
drawCommand( drawCommand(
@PutArg(0) name: string, @PutArg(0) name: string,
@PutArg(1) param: string,
@PutUserName() user: string, @PutUserName() user: string,
@PutCommonRenderer() renderer: CRenderer, @PutCommonRenderer() renderer: CRenderer,
) { ) {
const result = this.drawFromDeck(name); if (this.yamlDecks.has(name)) {
const deck = this.yamlDecks.get(name);
const result = deck.draw(param);
return result ?? renderer('.notFound');
}
const result = this.drawer.drawFromDeck(name);
if (!result) { if (!result) {
return renderer('.notFound'); return renderer('.notFound');
} }
...@@ -164,35 +154,59 @@ export default class DrawPlugin ...@@ -164,35 +154,59 @@ export default class DrawPlugin
@UseCommand('draw.list') @UseCommand('draw.list')
@CommandLocale('zh', localeZh.commands.list) @CommandLocale('zh', localeZh.commands.list)
@CommandLocale('en', localeEn.commands.list) @CommandLocale('en', localeEn.commands.list)
onListCommand(@PutRenderer('.fileInfo') renderer: Renderer) { onListCommand(
const entries = Array.from(this.deckFiles.entries()).map( @PutRenderer('.fileInfo') renderer: Renderer,
@PutRenderer('.yamlFileInfo') yamlRenderer: Renderer,
) {
const jsonEntries = Array.from(this.jsonDeckFiles.entries()).map(
([file, titles]) => ({ ([file, titles]) => ({
file, file,
count: titles.length, count: titles.length,
}), }),
); );
return entries.map((entry) => renderer(entry)).join('\n'); const yamlEntries = Array.from(this.yamlDeckFiles.entries()).map(
([file, deck]) => ({ ...deck.meta, file }),
);
return [
...jsonEntries.map((entry) => renderer(entry)),
...yamlEntries.map((entry) => yamlRenderer(entry)),
].join('\n');
} }
@UseCommand('draw.help') @UseCommand('draw.help')
@CommandLocale('zh', localeZh.commands.help) @CommandLocale('zh', localeZh.commands.help)
@CommandLocale('en', localeEn.commands.help) @CommandLocale('en', localeEn.commands.help)
onHelpCommand(@PutRenderer('.fileInfo') renderer: Renderer) { onHelpCommand(
const entries = Array.from(this.deckFiles.entries()).map( @PutRenderer('.fileInfo') renderer: Renderer,
@PutRenderer('.yamlFileInfo') yamlRenderer: Renderer,
) {
const jsonEntries = Array.from(this.jsonDeckFiles.entries()).map(
([file, titles]) => ({ ([file, titles]) => ({
file, file,
count: titles.length, count: titles.length,
titles, titles,
}), }),
); );
return entries const yamlDecks = Array.from(this.yamlDeckFiles.entries()).map(
.map( ([file, deck]) => ({ deck, file }),
);
return [
...jsonEntries.map(
(entry) => (entry) =>
renderer(entry) + renderer(entry) +
'\n' + '\n' +
entry.titles.map((title) => `draw ${title}`).join('\n'), entry.titles.map((title) => `draw ${title}`).join('\n'),
) ),
.join('\n'); ...yamlDecks.map(
(entry) =>
yamlRenderer(entry.deck.meta) +
'\n' +
entry.deck
.getCommands()
.map((command) => `draw ${command}`)
.join('\n'),
),
].join('\n');
} }
@UseCommand('draw.reload') @UseCommand('draw.reload')
...@@ -211,9 +225,10 @@ export default class DrawPlugin ...@@ -211,9 +225,10 @@ export default class DrawPlugin
@PutRenderer('.result') renderer: Renderer, @PutRenderer('.result') renderer: Renderer,
@PutRenderer('.notFound') notFoundRenderer: Renderer, @PutRenderer('.notFound') notFoundRenderer: Renderer,
) { ) {
const allDecks = _.flatten(Array.from(this.deckFiles.values())).filter( const allDecks = _.flatten([
(d) => d.includes(word), ...Array.from(this.jsonDeckFiles.values()),
); ...Array.from(this.yamlDeckFiles.values()).map((v) => v.getCommands()),
]).filter((d) => d.includes(word));
if (!allDecks.length) { if (!allDecks.length) {
return notFoundRenderer({ word }); return notFoundRenderer({ word });
} }
......
...@@ -2,6 +2,8 @@ import { Dict } from 'koishi'; ...@@ -2,6 +2,8 @@ import { Dict } from 'koishi';
import { CommandLocaleDef } from 'koishi-thirdeye'; import { CommandLocaleDef } from 'koishi-thirdeye';
export const fileInfo = 'File: {file} Count: {count}'; export const fileInfo = 'File: {file} Count: {count}';
const yamlFileInfo = '{name} Author: {author} Version: {version}\n{desc}';
const listMessages = { fileInfo, yamlFileInfo };
export const commands: Dict<CommandLocaleDef> = { export const commands: Dict<CommandLocaleDef> = {
draw: { draw: {
description: 'Draw from deck.', description: 'Draw from deck.',
...@@ -12,15 +14,11 @@ export const commands: Dict<CommandLocaleDef> = { ...@@ -12,15 +14,11 @@ export const commands: Dict<CommandLocaleDef> = {
}, },
list: { list: {
description: 'Check deck file list.', description: 'Check deck file list.',
messages: { messages: listMessages,
fileInfo,
},
}, },
help: { help: {
description: 'Check deck list.', description: 'Check deck list.',
messages: { messages: listMessages,
fileInfo,
},
}, },
reload: { reload: {
description: 'Reload deck files.', description: 'Reload deck files.',
......
import { CommandLocaleDef } from 'koishi-thirdeye'; import { CommandLocaleDef } from 'koishi-thirdeye';
import { Dict } from 'koishi'; import { Dict } from 'koishi';
export const fileInfo = '文件名: {file} 数量: {count}'; const fileInfo = '文件名: {file} 数量: {count}';
const yamlFileInfo = '{name} 文件名: {file} 作者: {author} 版本: {version}\n{desc}';
const listMessages = { fileInfo, yamlFileInfo };
export const commands: Dict<CommandLocaleDef> = { export const commands: Dict<CommandLocaleDef> = {
draw: { draw: {
description: '进行牌堆抽取', description: '进行牌堆抽取',
...@@ -12,15 +14,11 @@ export const commands: Dict<CommandLocaleDef> = { ...@@ -12,15 +14,11 @@ export const commands: Dict<CommandLocaleDef> = {
}, },
list: { list: {
description: '查看载入了哪些牌堆文件', description: '查看载入了哪些牌堆文件',
messages: { messages: listMessages,
fileInfo,
},
}, },
help: { help: {
description: '查看有哪些牌堆名可以使用', description: '查看有哪些牌堆名可以使用',
messages: { messages: listMessages,
fileInfo,
},
}, },
reload: { reload: {
description: '重新载入牌堆数据', description: '重新载入牌堆数据',
......
import { Logger } from 'koishi';
import { Drawer } from './drawer';
export interface YamlMeta {
name: string;
author: string;
version: number;
command: string;
desc: string;
includes: string[];
}
export type YamlStruct = YamlMeta & Record<string, string[]>;
export class YamlDrawer {
private availableDecks = new Set<string>();
private drawer = new Drawer(this.meta, this.logger, this.maxDepth);
constructor(
public meta: YamlStruct,
private logger: Logger,
private maxDepth: number,
) {
for (const deck of meta.includes ?? Object.keys(meta)) {
this.availableDecks.add(deck);
}
}
draw(deck: string, username?: string) {
deck ??= 'default';
if (!this.availableDecks.has(deck)) {
this.logger.warn(`Deck ${deck} not found.`);
return;
}
let result = this.drawer.drawFromDeck(deck);
if (result && username) {
result = result.replace(/【name】/g, username);
}
return result;
}
getCommands() {
return this.meta.includes.map((deck) =>
deck === 'default' ? this.meta.command : `${this.meta.command} ${deck}`,
);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment