// 配膳ロボットの試算(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);