diff options
Diffstat (limited to 'packages/frontend/src/scripts/hpml')
| -rw-r--r-- | packages/frontend/src/scripts/hpml/block.ts | 109 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/hpml/evaluator.ts | 232 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/hpml/expr.ts | 79 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/hpml/index.ts | 103 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/hpml/lib.ts | 247 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/hpml/type-checker.ts | 191 |
6 files changed, 961 insertions, 0 deletions
diff --git a/packages/frontend/src/scripts/hpml/block.ts b/packages/frontend/src/scripts/hpml/block.ts new file mode 100644 index 0000000000..804c5c1124 --- /dev/null +++ b/packages/frontend/src/scripts/hpml/block.ts @@ -0,0 +1,109 @@ +// blocks + +export type BlockBase = { + id: string; + type: string; +}; + +export type TextBlock = BlockBase & { + type: 'text'; + text: string; +}; + +export type SectionBlock = BlockBase & { + type: 'section'; + title: string; + children: (Block | VarBlock)[]; +}; + +export type ImageBlock = BlockBase & { + type: 'image'; + fileId: string | null; +}; + +export type ButtonBlock = BlockBase & { + type: 'button'; + text: any; + primary: boolean; + action: string; + content: string; + event: string; + message: string; + var: string; + fn: string; +}; + +export type IfBlock = BlockBase & { + type: 'if'; + var: string; + children: Block[]; +}; + +export type TextareaBlock = BlockBase & { + type: 'textarea'; + text: string; +}; + +export type PostBlock = BlockBase & { + type: 'post'; + text: string; + attachCanvasImage: boolean; + canvasId: string; +}; + +export type CanvasBlock = BlockBase & { + type: 'canvas'; + name: string; // canvas id + width: number; + height: number; +}; + +export type NoteBlock = BlockBase & { + type: 'note'; + detailed: boolean; + note: string | null; +}; + +export type Block = + TextBlock | SectionBlock | ImageBlock | ButtonBlock | IfBlock | TextareaBlock | PostBlock | CanvasBlock | NoteBlock | VarBlock; + +// variable blocks + +export type VarBlockBase = BlockBase & { + name: string; +}; + +export type NumberInputVarBlock = VarBlockBase & { + type: 'numberInput'; + text: string; +}; + +export type TextInputVarBlock = VarBlockBase & { + type: 'textInput'; + text: string; +}; + +export type SwitchVarBlock = VarBlockBase & { + type: 'switch'; + text: string; +}; + +export type RadioButtonVarBlock = VarBlockBase & { + type: 'radioButton'; + title: string; + values: string[]; +}; + +export type CounterVarBlock = VarBlockBase & { + type: 'counter'; + text: string; + inc: number; +}; + +export type VarBlock = + NumberInputVarBlock | TextInputVarBlock | SwitchVarBlock | RadioButtonVarBlock | CounterVarBlock; + +const varBlock = ['numberInput', 'textInput', 'switch', 'radioButton', 'counter']; +export function isVarBlock(block: Block): block is VarBlock { + return varBlock.includes(block.type); +} diff --git a/packages/frontend/src/scripts/hpml/evaluator.ts b/packages/frontend/src/scripts/hpml/evaluator.ts new file mode 100644 index 0000000000..196b3142a1 --- /dev/null +++ b/packages/frontend/src/scripts/hpml/evaluator.ts @@ -0,0 +1,232 @@ +import autobind from 'autobind-decorator'; +import { PageVar, envVarsDef, Fn, HpmlScope, HpmlError } from '.'; +import { version } from '@/config'; +import { AiScript, utils, values } from '@syuilo/aiscript'; +import { createAiScriptEnv } from '../aiscript/api'; +import { collectPageVars } from '../collect-page-vars'; +import { initHpmlLib, initAiLib } from './lib'; +import * as os from '@/os'; +import { markRaw, ref, Ref, unref } from 'vue'; +import { Expr, isLiteralValue, Variable } from './expr'; + +/** + * Hpml evaluator + */ +export class Hpml { + private variables: Variable[]; + private pageVars: PageVar[]; + private envVars: Record<keyof typeof envVarsDef, any>; + public aiscript?: AiScript; + public pageVarUpdatedCallback?: values.VFn; + public canvases: Record<string, HTMLCanvasElement> = {}; + public vars: Ref<Record<string, any>> = ref({}); + public page: Record<string, any>; + + private opts: { + randomSeed: string; visitor?: any; url?: string; + enableAiScript: boolean; + }; + + constructor(page: Hpml['page'], opts: Hpml['opts']) { + this.page = page; + this.variables = this.page.variables; + this.pageVars = collectPageVars(this.page.content); + this.opts = opts; + + if (this.opts.enableAiScript) { + this.aiscript = markRaw(new AiScript({ ...createAiScriptEnv({ + storageKey: 'pages:' + this.page.id, + }), ...initAiLib(this) }, { + in: (q) => { + return new Promise(ok => { + os.inputText({ + title: q, + }).then(({ canceled, result: a }) => { + ok(a); + }); + }); + }, + out: (value) => { + console.log(value); + }, + log: (type, params) => { + }, + })); + + this.aiscript.scope.opts.onUpdated = (name, value) => { + this.eval(); + }; + } + + const date = new Date(); + + this.envVars = { + AI: 'kawaii', + VERSION: version, + URL: this.page ? `${opts.url}/@${this.page.user.username}/pages/${this.page.name}` : '', + LOGIN: opts.visitor != null, + NAME: opts.visitor ? opts.visitor.name || opts.visitor.username : '', + USERNAME: opts.visitor ? opts.visitor.username : '', + USERID: opts.visitor ? opts.visitor.id : '', + NOTES_COUNT: opts.visitor ? opts.visitor.notesCount : 0, + FOLLOWERS_COUNT: opts.visitor ? opts.visitor.followersCount : 0, + FOLLOWING_COUNT: opts.visitor ? opts.visitor.followingCount : 0, + IS_CAT: opts.visitor ? opts.visitor.isCat : false, + SEED: opts.randomSeed ? opts.randomSeed : '', + YMD: `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`, + AISCRIPT_DISABLED: !this.opts.enableAiScript, + NULL: null, + }; + + this.eval(); + } + + @autobind + public eval() { + try { + this.vars.value = this.evaluateVars(); + } catch (err) { + //this.onError(e); + } + } + + @autobind + public interpolate(str: string) { + if (str == null) return null; + return str.replace(/{(.+?)}/g, match => { + const v = unref(this.vars)[match.slice(1, -1).trim()]; + return v == null ? 'NULL' : v.toString(); + }); + } + + @autobind + public callAiScript(fn: string) { + try { + if (this.aiscript) this.aiscript.execFn(this.aiscript.scope.get(fn), []); + } catch (err) {} + } + + @autobind + public registerCanvas(id: string, canvas: any) { + this.canvases[id] = canvas; + } + + @autobind + public updatePageVar(name: string, value: any) { + const pageVar = this.pageVars.find(v => v.name === name); + if (pageVar !== undefined) { + pageVar.value = value; + if (this.pageVarUpdatedCallback) { + if (this.aiscript) this.aiscript.execFn(this.pageVarUpdatedCallback, [values.STR(name), utils.jsToVal(value)]); + } + } else { + throw new HpmlError(`No such page var '${name}'`); + } + } + + @autobind + public updateRandomSeed(seed: string) { + this.opts.randomSeed = seed; + this.envVars.SEED = seed; + } + + @autobind + private _interpolateScope(str: string, scope: HpmlScope) { + return str.replace(/{(.+?)}/g, match => { + const v = scope.getState(match.slice(1, -1).trim()); + return v == null ? 'NULL' : v.toString(); + }); + } + + @autobind + public evaluateVars(): Record<string, any> { + const values: Record<string, any> = {}; + + for (const [k, v] of Object.entries(this.envVars)) { + values[k] = v; + } + + for (const v of this.pageVars) { + values[v.name] = v.value; + } + + for (const v of this.variables) { + values[v.name] = this.evaluate(v, new HpmlScope([values])); + } + + return values; + } + + @autobind + private evaluate(expr: Expr, scope: HpmlScope): any { + if (isLiteralValue(expr)) { + if (expr.type === null) { + return null; + } + + if (expr.type === 'number') { + return parseInt((expr.value as any), 10); + } + + if (expr.type === 'text' || expr.type === 'multiLineText') { + return this._interpolateScope(expr.value || '', scope); + } + + if (expr.type === 'textList') { + return this._interpolateScope(expr.value || '', scope).trim().split('\n'); + } + + if (expr.type === 'ref') { + return scope.getState(expr.value); + } + + if (expr.type === 'aiScriptVar') { + if (this.aiscript) { + try { + return utils.valToJs(this.aiscript.scope.get(expr.value)); + } catch (err) { + return null; + } + } else { + return null; + } + } + + // Define user function + if (expr.type === 'fn') { + return { + slots: expr.value.slots.map(x => x.name), + exec: (slotArg: Record<string, any>) => { + return this.evaluate(expr.value.expression, scope.createChildScope(slotArg, expr.id)); + }, + } as Fn; + } + return; + } + + // Call user function + if (expr.type.startsWith('fn:')) { + const fnName = expr.type.split(':')[1]; + const fn = scope.getState(fnName); + const args = {} as Record<string, any>; + for (let i = 0; i < fn.slots.length; i++) { + const name = fn.slots[i]; + args[name] = this.evaluate(expr.args[i], scope); + } + return fn.exec(args); + } + + if (expr.args === undefined) return null; + + const funcs = initHpmlLib(expr, scope, this.opts.randomSeed, this.opts.visitor); + + // Call function + const fnName = expr.type; + const fn = (funcs as any)[fnName]; + if (fn == null) { + throw new HpmlError(`No such function '${fnName}'`); + } else { + return fn(...expr.args.map(x => this.evaluate(x, scope))); + } + } +} diff --git a/packages/frontend/src/scripts/hpml/expr.ts b/packages/frontend/src/scripts/hpml/expr.ts new file mode 100644 index 0000000000..18c7c2a14b --- /dev/null +++ b/packages/frontend/src/scripts/hpml/expr.ts @@ -0,0 +1,79 @@ +import { literalDefs, Type } from '.'; + +export type ExprBase = { + id: string; +}; + +// value + +export type EmptyValue = ExprBase & { + type: null; + value: null; +}; + +export type TextValue = ExprBase & { + type: 'text'; + value: string; +}; + +export type MultiLineTextValue = ExprBase & { + type: 'multiLineText'; + value: string; +}; + +export type TextListValue = ExprBase & { + type: 'textList'; + value: string; +}; + +export type NumberValue = ExprBase & { + type: 'number'; + value: number; +}; + +export type RefValue = ExprBase & { + type: 'ref'; + value: string; // value is variable name +}; + +export type AiScriptRefValue = ExprBase & { + type: 'aiScriptVar'; + value: string; // value is variable name +}; + +export type UserFnValue = ExprBase & { + type: 'fn'; + value: UserFnInnerValue; +}; +type UserFnInnerValue = { + slots: { + name: string; + type: Type; + }[]; + expression: Expr; +}; + +export type Value = + EmptyValue | TextValue | MultiLineTextValue | TextListValue | NumberValue | RefValue | AiScriptRefValue | UserFnValue; + +export function isLiteralValue(expr: Expr): expr is Value { + if (expr.type == null) return true; + if (literalDefs[expr.type]) return true; + return false; +} + +// call function + +export type CallFn = ExprBase & { // "fn:hoge" or string + type: string; + args: Expr[]; + value: null; +}; + +// variable +export type Variable = (Value | CallFn) & { + name: string; +}; + +// expression +export type Expr = Variable | Value | CallFn; diff --git a/packages/frontend/src/scripts/hpml/index.ts b/packages/frontend/src/scripts/hpml/index.ts new file mode 100644 index 0000000000..9a55a5c286 --- /dev/null +++ b/packages/frontend/src/scripts/hpml/index.ts @@ -0,0 +1,103 @@ +/** + * Hpml + */ + +import autobind from 'autobind-decorator'; +import { Hpml } from './evaluator'; +import { funcDefs } from './lib'; + +export type Fn = { + slots: string[]; + exec: (args: Record<string, any>) => ReturnType<Hpml['evaluate']>; +}; + +export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null; + +export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = { + text: { out: 'string', category: 'value', icon: 'ti ti-quote' }, + multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left' }, + textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list' }, + number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up' }, + ref: { out: null, category: 'value', icon: 'fas fa-magic' }, + aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic' }, + fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt' }, +}; + +export const blockDefs = [ + ...Object.entries(literalDefs).map(([k, v]) => ({ + type: k, out: v.out, category: v.category, icon: v.icon, + })), + ...Object.entries(funcDefs).map(([k, v]) => ({ + type: k, out: v.out, category: v.category, icon: v.icon, + })), +]; + +export type PageVar = { name: string; value: any; type: Type; }; + +export const envVarsDef: Record<string, Type> = { + AI: 'string', + URL: 'string', + VERSION: 'string', + LOGIN: 'boolean', + NAME: 'string', + USERNAME: 'string', + USERID: 'string', + NOTES_COUNT: 'number', + FOLLOWERS_COUNT: 'number', + FOLLOWING_COUNT: 'number', + IS_CAT: 'boolean', + SEED: null, + YMD: 'string', + AISCRIPT_DISABLED: 'boolean', + NULL: null, +}; + +export class HpmlScope { + private layerdStates: Record<string, any>[]; + public name: string; + + constructor(layerdStates: HpmlScope['layerdStates'], name?: HpmlScope['name']) { + this.layerdStates = layerdStates; + this.name = name || 'anonymous'; + } + + @autobind + public createChildScope(states: Record<string, any>, name?: HpmlScope['name']): HpmlScope { + const layer = [states, ...this.layerdStates]; + return new HpmlScope(layer, name); + } + + /** + * 指定した名前の変数の値を取得します + * @param name 変数名 + */ + @autobind + public getState(name: string): any { + for (const later of this.layerdStates) { + const state = later[name]; + if (state !== undefined) { + return state; + } + } + + throw new HpmlError( + `No such variable '${name}' in scope '${this.name}'`, { + scope: this.layerdStates, + }); + } +} + +export class HpmlError extends Error { + public info?: any; + + constructor(message: string, info?: any) { + super(message); + + this.info = info; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, HpmlError); + } + } +} diff --git a/packages/frontend/src/scripts/hpml/lib.ts b/packages/frontend/src/scripts/hpml/lib.ts new file mode 100644 index 0000000000..b684876a7f --- /dev/null +++ b/packages/frontend/src/scripts/hpml/lib.ts @@ -0,0 +1,247 @@ +import tinycolor from 'tinycolor2'; +import { Hpml } from './evaluator'; +import { values, utils } from '@syuilo/aiscript'; +import { Fn, HpmlScope } from '.'; +import { Expr } from './expr'; +import seedrandom from 'seedrandom'; + +/* TODO: https://www.chartjs.org/docs/latest/configuration/canvas-background.html#color +// https://stackoverflow.com/questions/38493564/chart-area-background-color-chartjs +Chart.pluginService.register({ + beforeDraw: (chart, easing) => { + if (chart.config.options.chartArea && chart.config.options.chartArea.backgroundColor) { + const ctx = chart.chart.ctx; + ctx.save(); + ctx.fillStyle = chart.config.options.chartArea.backgroundColor; + ctx.fillRect(0, 0, chart.chart.width, chart.chart.height); + ctx.restore(); + } + } +}); +*/ + +export function initAiLib(hpml: Hpml) { + return { + 'MkPages:updated': values.FN_NATIVE(([callback]) => { + hpml.pageVarUpdatedCallback = (callback as values.VFn); + }), + 'MkPages:get_canvas': values.FN_NATIVE(([id]) => { + utils.assertString(id); + const canvas = hpml.canvases[id.value]; + const ctx = canvas.getContext('2d'); + return values.OBJ(new Map([ + ['clear_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.clearRect(x.value, y.value, width.value, height.value); })], + ['fill_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.fillRect(x.value, y.value, width.value, height.value); })], + ['stroke_rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.strokeRect(x.value, y.value, width.value, height.value); })], + ['fill_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.fillText(text.value, x.value, y.value, width ? width.value : undefined); })], + ['stroke_text', values.FN_NATIVE(([text, x, y, width]) => { ctx.strokeText(text.value, x.value, y.value, width ? width.value : undefined); })], + ['set_line_width', values.FN_NATIVE(([width]) => { ctx.lineWidth = width.value; })], + ['set_font', values.FN_NATIVE(([font]) => { ctx.font = font.value; })], + ['set_fill_style', values.FN_NATIVE(([style]) => { ctx.fillStyle = style.value; })], + ['set_stroke_style', values.FN_NATIVE(([style]) => { ctx.strokeStyle = style.value; })], + ['begin_path', values.FN_NATIVE(() => { ctx.beginPath(); })], + ['close_path', values.FN_NATIVE(() => { ctx.closePath(); })], + ['move_to', values.FN_NATIVE(([x, y]) => { ctx.moveTo(x.value, y.value); })], + ['line_to', values.FN_NATIVE(([x, y]) => { ctx.lineTo(x.value, y.value); })], + ['arc', values.FN_NATIVE(([x, y, radius, startAngle, endAngle]) => { ctx.arc(x.value, y.value, radius.value, startAngle.value, endAngle.value); })], + ['rect', values.FN_NATIVE(([x, y, width, height]) => { ctx.rect(x.value, y.value, width.value, height.value); })], + ['fill', values.FN_NATIVE(() => { ctx.fill(); })], + ['stroke', values.FN_NATIVE(() => { ctx.stroke(); })], + ])); + }), + 'MkPages:chart': values.FN_NATIVE(([id, opts]) => { + /* TODO + utils.assertString(id); + utils.assertObject(opts); + const canvas = hpml.canvases[id.value]; + const color = getComputedStyle(document.documentElement).getPropertyValue('--accent'); + Chart.defaults.color = '#555'; + const chart = new Chart(canvas, { + type: opts.value.get('type').value, + data: { + labels: opts.value.get('labels').value.map(x => x.value), + datasets: opts.value.get('datasets').value.map(x => ({ + label: x.value.has('label') ? x.value.get('label').value : '', + data: x.value.get('data').value.map(x => x.value), + pointRadius: 0, + lineTension: 0, + borderWidth: 2, + borderColor: x.value.has('color') ? x.value.get('color') : color, + backgroundColor: tinycolor(x.value.has('color') ? x.value.get('color') : color).setAlpha(0.1).toRgbString(), + })) + }, + options: { + responsive: false, + devicePixelRatio: 1.5, + title: { + display: opts.value.has('title'), + text: opts.value.has('title') ? opts.value.get('title').value : '', + fontSize: 14, + }, + layout: { + padding: { + left: 32, + right: 32, + top: opts.value.has('title') ? 16 : 32, + bottom: 16 + } + }, + legend: { + display: opts.value.get('datasets').value.filter(x => x.value.has('label') && x.value.get('label').value).length === 0 ? false : true, + position: 'bottom', + labels: { + boxWidth: 16, + } + }, + tooltips: { + enabled: false, + }, + chartArea: { + backgroundColor: '#fff' + }, + ...(opts.value.get('type').value === 'radar' ? { + scale: { + ticks: { + display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : false, + min: opts.value.has('min') ? opts.value.get('min').value : undefined, + max: opts.value.has('max') ? opts.value.get('max').value : undefined, + maxTicksLimit: 8, + }, + pointLabels: { + fontSize: 12 + } + } + } : { + scales: { + yAxes: [{ + ticks: { + display: opts.value.has('show_tick_label') ? opts.value.get('show_tick_label').value : true, + min: opts.value.has('min') ? opts.value.get('min').value : undefined, + max: opts.value.has('max') ? opts.value.get('max').value : undefined, + } + }] + } + }) + } + }); + */ + }), + }; +} + +export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = { + if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'ti ti-share' }, + for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle' }, + not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-plus' }, + subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-minus' }, + multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'ti ti-x' }, + divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' }, + mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' }, + round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator' }, + eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals' }, + notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal' }, + gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than' }, + lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than' }, + gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal' }, + ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal' }, + strLen: { in: ['string'], out: 'number', category: 'text', icon: 'ti ti-quote' }, + strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'ti ti-quote' }, + stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt' }, + numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt' }, + splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt' }, + pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent' }, + listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent' }, + rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' }, + dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' }, + seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice' }, + DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice' }, // dailyRandomPickWithProbabilityMapping +}; + +export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) { + const date = new Date(); + const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; + + // SHOULD be fine to ignore since it's intended + function shape isn't defined + // eslint-disable-next-line @typescript-eslint/ban-types + const funcs: Record<string, Function> = { + not: (a: boolean) => !a, + or: (a: boolean, b: boolean) => a || b, + and: (a: boolean, b: boolean) => a && b, + eq: (a: any, b: any) => a === b, + notEq: (a: any, b: any) => a !== b, + gt: (a: number, b: number) => a > b, + lt: (a: number, b: number) => a < b, + gtEq: (a: number, b: number) => a >= b, + ltEq: (a: number, b: number) => a <= b, + if: (bool: boolean, a: any, b: any) => bool ? a : b, + for: (times: number, fn: Fn) => { + const result: any[] = []; + for (let i = 0; i < times; i++) { + result.push(fn.exec({ + [fn.slots[0]]: i + 1, + })); + } + return result; + }, + add: (a: number, b: number) => a + b, + subtract: (a: number, b: number) => a - b, + multiply: (a: number, b: number) => a * b, + divide: (a: number, b: number) => a / b, + mod: (a: number, b: number) => a % b, + round: (a: number) => Math.round(a), + strLen: (a: string) => a.length, + strPick: (a: string, b: number) => a[b - 1], + strReplace: (a: string, b: string, c: string) => a.split(b).join(c), + strReverse: (a: string) => a.split('').reverse().join(''), + join: (texts: string[], separator: string) => texts.join(separator || ''), + stringToNumber: (a: string) => parseInt(a), + numberToString: (a: number) => a.toString(), + splitStrByLine: (a: string) => a.split('\n'), + pick: (list: any[], i: number) => list[i - 1], + listLen: (list: any[]) => list.length, + random: (probability: number) => Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * 100) < probability, + rannum: (min: number, max: number) => min + Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * (max - min + 1)), + randomPick: (list: any[]) => list[Math.floor(seedrandom(`${randomSeed}:${expr.id}`)() * list.length)], + dailyRandom: (probability: number) => Math.floor(seedrandom(`${day}:${expr.id}`)() * 100) < probability, + dailyRannum: (min: number, max: number) => min + Math.floor(seedrandom(`${day}:${expr.id}`)() * (max - min + 1)), + dailyRandomPick: (list: any[]) => list[Math.floor(seedrandom(`${day}:${expr.id}`)() * list.length)], + seedRandom: (seed: any, probability: number) => Math.floor(seedrandom(seed)() * 100) < probability, + seedRannum: (seed: any, min: number, max: number) => min + Math.floor(seedrandom(seed)() * (max - min + 1)), + seedRandomPick: (seed: any, list: any[]) => list[Math.floor(seedrandom(seed)() * list.length)], + DRPWPM: (list: string[]) => { + const xs: any[] = []; + let totalFactor = 0; + for (const x of list) { + const parts = x.split(' '); + const factor = parseInt(parts.pop()!, 10); + const text = parts.join(' '); + totalFactor += factor; + xs.push({ factor, text }); + } + const r = seedrandom(`${day}:${expr.id}`)() * totalFactor; + let stackedFactor = 0; + for (const x of xs) { + if (r >= stackedFactor && r <= stackedFactor + x.factor) { + return x.text; + } else { + stackedFactor += x.factor; + } + } + return xs[0].text; + }, + }; + + return funcs; +} diff --git a/packages/frontend/src/scripts/hpml/type-checker.ts b/packages/frontend/src/scripts/hpml/type-checker.ts new file mode 100644 index 0000000000..24c9ed8bcb --- /dev/null +++ b/packages/frontend/src/scripts/hpml/type-checker.ts @@ -0,0 +1,191 @@ +import autobind from 'autobind-decorator'; +import { isLiteralValue } from './expr'; +import { funcDefs } from './lib'; +import { envVarsDef } from '.'; +import type { Type, PageVar } from '.'; +import type { Expr, Variable } from './expr'; + +type TypeError = { + arg: number; + expect: Type; + actual: Type; +}; + +/** + * Hpml type checker + */ +export class HpmlTypeChecker { + public variables: Variable[]; + public pageVars: PageVar[]; + + constructor(variables: HpmlTypeChecker['variables'] = [], pageVars: HpmlTypeChecker['pageVars'] = []) { + this.variables = variables; + this.pageVars = pageVars; + } + + @autobind + public typeCheck(v: Expr): TypeError | null { + if (isLiteralValue(v)) return null; + + const def = funcDefs[v.type || '']; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.infer(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } else if (type !== generic[arg]) { + return { + arg: i, + expect: generic[arg], + actual: type, + }; + } + } else if (type !== arg) { + return { + arg: i, + expect: arg, + actual: type, + }; + } + } + + return null; + } + + @autobind + public getExpectedType(v: Expr, slot: number): Type { + const def = funcDefs[v.type || '']; + if (def == null) { + throw new Error('Unknown type: ' + v.type); + } + + const generic: Type[] = []; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + const type = this.infer(v.args[i]); + if (type === null) continue; + + if (typeof arg === 'number') { + if (generic[arg] === undefined) { + generic[arg] = type; + } + } + } + + if (typeof def.in[slot] === 'number') { + return generic[def.in[slot]] ?? null; + } else { + return def.in[slot]; + } + } + + @autobind + public infer(v: Expr): Type { + if (v.type === null) return null; + if (v.type === 'text') return 'string'; + if (v.type === 'multiLineText') return 'string'; + if (v.type === 'textList') return 'stringArray'; + if (v.type === 'number') return 'number'; + if (v.type === 'ref') { + const variable = this.variables.find(va => va.name === v.value); + if (variable) { + return this.infer(variable); + } + + const pageVar = this.pageVars.find(va => va.name === v.value); + if (pageVar) { + return pageVar.type; + } + + const envVar = envVarsDef[v.value || '']; + if (envVar !== undefined) { + return envVar; + } + + return null; + } + if (v.type === 'aiScriptVar') return null; + if (v.type === 'fn') return null; // todo + if (v.type.startsWith('fn:')) return null; // todo + + const generic: Type[] = []; + + const def = funcDefs[v.type]; + + for (let i = 0; i < def.in.length; i++) { + const arg = def.in[i]; + if (typeof arg === 'number') { + const type = this.infer(v.args[i]); + + if (generic[arg] === undefined) { + generic[arg] = type; + } else { + if (type !== generic[arg]) { + generic[arg] = null; + } + } + } + } + + if (typeof def.out === 'number') { + return generic[def.out]; + } else { + return def.out; + } + } + + @autobind + public getVarByName(name: string): Variable { + const v = this.variables.find(x => x.name === name); + if (v !== undefined) { + return v; + } else { + throw new Error(`No such variable '${name}'`); + } + } + + @autobind + public getVarsByType(type: Type): Variable[] { + if (type == null) return this.variables; + return this.variables.filter(x => (this.infer(x) === null) || (this.infer(x) === type)); + } + + @autobind + public getEnvVarsByType(type: Type): string[] { + if (type == null) return Object.keys(envVarsDef); + return Object.entries(envVarsDef).filter(([k, v]) => v === null || type === v).map(([k, v]) => k); + } + + @autobind + public getPageVarsByType(type: Type): string[] { + if (type == null) return this.pageVars.map(v => v.name); + return this.pageVars.filter(v => type === v.type).map(v => v.name); + } + + @autobind + public isUsedName(name: string) { + if (this.variables.some(v => v.name === name)) { + return true; + } + + if (this.pageVars.some(v => v.name === name)) { + return true; + } + + if (envVarsDef[name]) { + return true; + } + + return false; + } +} |