// 配膳ロボットの試算(public/sim/serving-robot.html)の結果を、ビルドのときに読む。
// LP(/serving-robot-one-less-staff)の数字は、すべてここから出す。試算が正なので、試算を直せば LP の数字も次のビルドで変わる。
// 新しい計算はしない。試算のページと同じ決め方(find・minStaff・費用の既定値)で読むだけ。
import fs from 'node:fs';
import path from 'node:path';
type Row = Record;
type Store = Record;
const html = fs.readFileSync(path.join(process.cwd(), 'public/sim/serving-robot.html'), 'utf8');
const at = html.indexOf('const D = ') + 'const D = '.length;
// 試算のページに埋め込まれている D(JSON)を1つだけ読み出す
function readJson(src: string, i: number) {
let depth = 0, inStr = false, esc = false;
for (let j = i; j < src.length; j++) {
const c = src[j];
if (inStr) { if (esc) esc = false; else if (c === '\\') esc = true; else if (c === '"') inStr = false; continue; }
if (c === '"') inStr = true;
else if (c === '{' || c === '[') depth++;
else if (c === '}' || c === ']') { depth--; if (depth === 0) return JSON.parse(src.slice(i, j + 1)); }
}
throw new Error('試算のデータ(const D)が読めません');
}
const D = readJson(html, at);
const K: string[] = D.k;
const ROWS: Row[] = D.r.map((r: any[]) => Object.fromEntries(K.map((k, i) => [k, r[i]])));
export const STORES: Record = D.stores;
const IDX = new Map(ROWS.map((r) => [[r.store, r.staff, r.robots, r.arrivals_per_h, r.mode, r.trays, r.fragile_by].join('|'), r]));
// 試算のページの既定(1往復で3卓まで・壊れやすい皿は誰が運んでもよい)
const TRAYS = 3, FB = 'any';
export function find(store: string, staff: number, robots: number, arr: number, mode = 'serve'): Row | undefined {
const k = robots === 0 ? [store, staff, 0, arr, 'serve', 1, 'any'] : [store, staff, robots, arr, mode, TRAYS, FB];
return IDX.get(k.join('|'));
}
export const unstable = (r: Row) => r.ng === 1;
export function minStaff(store: string, robots: number, arr: number, mode = 'serve'): number | null {
for (const st of STORES[store].staff_all) {
const r = find(store, st, robots, arr, mode);
if (r && !unstable(r)) return st;
}
return null;
}
// 費用の既定(試算のページの COST と同じ)
export const COST = { rent: 60000, buy: 2500000, maint: 10000, charge: 2000, years: 5, wage: 1177, peaks: 2, days: 30, shiftH: 4, subRate: 0.5, subCap: 7500000 };
export const robotMonthRent = (n: number) => n * (COST.rent + COST.maint + COST.charge);
export const savedPerStaff = (hours: number) => hours * COST.peaks * COST.days * COST.wage;
export const CONDITIONS = ROWS.length; // 条件の数
export const MODES = ['serve', 'bus', 'patrol'];
// 標準ファミレス・普通の混み具合・料理を運ぶだけ
const fam = STORES.family;
const famArr = fam.arrivals[1];
const base = find('family', 3, 0, famArr)!;
const less = find('family', 2, 2, famArr)!;
const same = find('family', 3, 2, famArr)!;
export const headline = { tables: fam.tables, arrivals: famArr, base, less, same };
// 2台入れたとき、店が回る最少のスタッフが何人減るか(店すべて × 混み具合3つ × 使い方3つ)
const twoRobots = { none: 0, one: 0, onePeak: [] as number[], total: 0 };
for (const [key, s] of Object.entries(STORES)) {
for (const arr of s.arrivals) for (const mode of MODES) {
const a = minStaff(key, 0, arr, mode), b = minStaff(key, 2, arr, mode);
if (a == null || b == null) continue;
twoRobots.total++;
if (a - b <= 0) twoRobots.none++; else { twoRobots.one++; twoRobots.onePeak.push(s.peak_min); }
}
}
export { twoRobots };
// 店の種類5つ × 混み具合3つ:ロボット0〜3台の、店が回る最少のスタッフ(料理を運ぶだけ)
export const BASIC_STORES = ['small', 'family', 'large', 'hotel', 'buffet'];
export const staffTable = BASIC_STORES.map((k) => ({
key: k, label: STORES[k].label, tables: STORES[k].tables, peak: STORES[k].peak_min,
crowd: STORES[k].arrivals.map((arr: number) => ({ arr, staff: [0, 1, 2, 3].map((n) => minStaff(k, n, arr)) })),
}));
// 図面のある店6つ:真ん中の人数・普通の混み具合で、ロボット0台 → 2台
export const plans = Object.keys(STORES).filter((k) => STORES[k].layout).map((k) => {
const s = STORES[k], st = s.staff[1], arr = s.arrivals[1];
const a = find(k, st, 0, arr)!, b = find(k, st, 2, arr)!;
const label: string = s.label.replace(/^標準ファミレス(|)$/g, '');
return { key: k, file: s.layout, label, num: label.slice(0, 1), name: label.slice(2), tables: s.tables, staff: st, arrivals: arr, from: a.entry_wait_mean, to: b.entry_wait_mean, ngFrom: unstable(a), ngTo: unstable(b) };
});
// 大型店・混雑で、2台を買って補助金を使った場合
const lg = STORES.large, lgArr = lg.arrivals[2];
const lgFrom = minStaff('large', 0, lgArr)!, lgTo = minStaff('large', 2, lgArr)!;
const buyTotal = 2 * COST.buy, sub = Math.min(buyTotal * COST.subRate, COST.subCap), first = buyTotal - sub;
const run = 2 * (COST.maint + COST.charge);
const buyMonth = first / (COST.years * 12) + run;
const lgSaved = (lgFrom - lgTo) * savedPerStaff(lg.peak_min / 60);
export const buyLarge = { from: lgFrom, to: lgTo, first, buyMonth, saved: lgSaved, net: lgSaved - buyMonth, payback: Math.ceil(first / (lgSaved - run)) };
// 表示の形
export const min1 = (v: number) => (Math.round(v * 10) / 10).toFixed(1);
export const sec = (vMin: number) => Math.round(vMin * 60);
export const man = (yen: number) => {
const a = Math.round(Math.abs(yen) / 1000) / 10;
return (yen < 0 ? '−' : yen > 0 ? '+' : '') + (Number.isInteger(a) ? String(a) : a.toFixed(1));
};
export const pct = (v: number) => Math.round(v * 100);