- 옷장(@4MOGH4)님의 시트를 기반으로 만들어서, 옷장님의 시트로만 사용가능합니다! (항상 잘쓰고 있습니다, 언제나 감사합니다 옷장님!)

[설치 방법]

1. 구글 스프레드의 app script 기반으로 만들어져있기 때문에,

상단 메뉴에 있는 확장프로그램을 눌러 APP SCRIPT로 들어가주세요.

2. 그럼 아래와 같은 화면이 나옵니다.

Code.js에 있는 코드를 지우고, 밑에 있는 코드를 복사해서 넣어주세요.

Javascript
// ===== DX3rd Effect Calculator =====

var CACHE_VERSION = 5;
var SHEET_ROWS = 193;
var SHEET_COLS = 37;
var UI_STATE_CELL_A1 = 'AG240';

// ---- Sheet Layout Constants ----
// 시트 구조가 바뀌면 여기만 수정
var CELL = {
  NAME:         [15, 37],
  NAME_FALLBACK:[8, 23],
  HP:           [16, 15],
  EROSION:      [16, 19],
  INITIATIVE:   [16, 22],
  COMBAT_MOVE:  [16, 24],
  FULL_MOVE:    [17, 24],
  STAT_ROW:     33,
  STAT_COLS:    { physical: 6, sense: 14, mental: 22, social: 30 }
};
var SKILL = {
  START_ROW: 36,
  END_ROW:   39,
  CONFIGS: [
    { key: 'physical', nameCol: 2, valCol: 8 },
    { key: 'sense',    nameCol: 10, valCol: 15 },
    { key: 'mental',   nameCol: 18, valCol: 23 },
    { key: 'social',   nameCol: 25, valCol: 31 }
  ]
};
var TABLE_RANGE = {
  WEAPONS:      { start: 91,  end: 95 },
  ARMORS:       { start: 100, end: 104 },
  VEHICLES:     { start: 109, end: 111 },
  EXTRA_EFFECTS:{ start: 164, end: 168 },
  EFFECTS:      { start: 172, end: 193 }
};
var EFFECT_COLS = {
  NAME: 3, LV: 8, TIMING: 10, FEATURE: 12, DIFF: 14,
  TARGET: 17, RANGE: 19, EROSION: 21, LIMIT: 23, EFFECT: 25
};

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('DX3 \uacc4\uc0b0\uae30')
    .addItem('\uc774\ud399\ud2b8 \uacc4\uc0b0\uae30 \uc5f4\uae30', 'showSidebar')
    .addToUi();
}

function showSidebar() {
  var html = HtmlService.createHtmlOutputFromFile('Sidebar')
    .setTitle('DX3rd \uc774\ud399\ud2b8 \uacc4\uc0b0\uae30')
    .setSandboxMode(HtmlService.SandboxMode.IFRAME);
  SpreadsheetApp.getUi().showSidebar(html);
}

function getPcSheetNames() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheetNames = ss.getSheets()
    .filter(function(ws) {
      if (ws.isSheetHidden()) return false;
      var name = ws.getName();
      return /PC/i.test(name) && !/계산기/i.test(name);
    })
    .map(function(ws) { return ws.getName(); });
  return {
    spreadsheetId: ss.getId(),
    sheetNames: sheetNames
  };
}

function getAllCharacterData(forceRefresh) {
  var bootstrap = getPcSheetNames();
  var sheetNames = bootstrap.sheetNames || [];
  var refresh = !!forceRefresh;
  var dataBySheet = {};
  sheetNames.forEach(function(sn) {
    try {
      dataBySheet[sn] = getCharacterData(sn, refresh);
    } catch (e) {
      dataBySheet[sn] = { error: '시트 로드 오류: ' + sn + ' / ' + (e && e.message ? e.message : e) };
    }
  });
  return {
    spreadsheetId: bootstrap.spreadsheetId || '',
    sheetNames: sheetNames,
    dataBySheet: dataBySheet,
    fetchedAt: new Date().toISOString()
  };
}

function getCharacterDataVersion(sheetName) {
  var sn = String(sheetName || '').trim();
  if (!sn) return { sheetName: sn, version: '', error: '\uc2dc\ud2b8 \uc774\ub984 \uc5c6\uc74c' };
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var ws = ss.getSheetByName(sn);
  if (!ws) return { sheetName: sn, version: '', error: '\uc2dc\ud2b8 \uc5c6\uc74c: ' + sn };
  var version = buildCharacterVersionToken_(ws);
  return { sheetName: sn, version: version };
}

function getCharacterUiState(sheetName) {
  var sn = String(sheetName || '').trim();
  if (!sn) return { ok: false, sheetName: sn, cellA1: UI_STATE_CELL_A1, error: '\uc2dc\ud2b8 \uc774\ub984 \uc5c6\uc74c' };
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var ws = ss.getSheetByName(sn);
  if (!ws) return { ok: false, sheetName: sn, cellA1: UI_STATE_CELL_A1, error: '\uc2dc\ud2b8 \uc5c6\uc74c: ' + sn };

  var raw = String(ws.getRange(UI_STATE_CELL_A1).getValue() || '').trim();
  if (!raw) {
    return {
      ok: true,
      exists: false,
      sheetName: sn,
      cellA1: UI_STATE_CELL_A1,
      payload: null,
      state: null
    };
  }

  try {
    var payload = JSON.parse(raw);
    var state = (payload && payload.state && typeof payload.state === 'object') ? payload.state : payload;
    return {
      ok: true,
      exists: true,
      sheetName: sn,
      cellA1: UI_STATE_CELL_A1,
      payload: payload,
      state: (state && typeof state === 'object') ? state : null
    };
  } catch (e) {
    return {
      ok: false,
      exists: true,
      sheetName: sn,
      cellA1: UI_STATE_CELL_A1,
      error: '\uc2dc\ud2b8 \uc800\uc7a5 \ub370\uc774\ud130 JSON \ud30c\uc2f1 \uc2e4\ud328: ' + (e && e.message ? e.message : e)
    };
  }
}

function normalizeCharacterUiStatePayload_(sheetName, payload) {
  var sn = String(sheetName || '').trim();
  var nextPayload = (payload && typeof payload === 'object') ? payload : {};
  if (!nextPayload.state || typeof nextPayload.state !== 'object') {
    nextPayload = {
      version: 2,
      sheetName: sn,
      savedAt: new Date().toISOString(),
      state: nextPayload
    };
  }
  nextPayload.sheetName = sn;
  if (!nextPayload.version) nextPayload.version = 2;
  if (!nextPayload.savedAt) nextPayload.savedAt = new Date().toISOString();
  return nextPayload;
}

function setCharacterUiState(sheetName, payload) {
  var sn = String(sheetName || '').trim();
  if (!sn) return { ok: false, sheetName: sn, cellA1: UI_STATE_CELL_A1, error: '\uc2dc\ud2b8 \uc774\ub984 \uc5c6\uc74c' };
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var ws = ss.getSheetByName(sn);
  if (!ws) return { ok: false, sheetName: sn, cellA1: UI_STATE_CELL_A1, error: '\uc2dc\ud2b8 \uc5c6\uc74c: ' + sn };

  var nextPayload = normalizeCharacterUiStatePayload_(sn, payload);
  var raw = JSON.stringify(nextPayload);
  ws.getRange(UI_STATE_CELL_A1).setValue(raw);
  return {
    ok: true,
    sheetName: sn,
    cellA1: UI_STATE_CELL_A1,
    savedAt: nextPayload.savedAt || new Date().toISOString(),
    size: raw.length
  };
}

function setAllCharacterUiStates(payloadBySheet) {
  var src = (payloadBySheet && typeof payloadBySheet === 'object') ? payloadBySheet : {};
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var out = {
    ok: true,
    cellA1: UI_STATE_CELL_A1,
    requestedCount: 0,
    savedCount: 0,
    errorCount: 0,
    savedSheets: [],
    errors: {}
  };

  Object.keys(src).forEach(function(rawSheetName) {
    var sn = String(rawSheetName || '').trim();
    if (!sn) return;
    out.requestedCount += 1;
    try {
      var ws = ss.getSheetByName(sn);
      if (!ws) {
        out.errorCount += 1;
        out.errors[sn] = '\uc2dc\ud2b8 \uc5c6\uc74c: ' + sn;
        return;
      }
      var nextPayload = normalizeCharacterUiStatePayload_(sn, src[rawSheetName]);
      var raw = JSON.stringify(nextPayload);
      ws.getRange(UI_STATE_CELL_A1).setValue(raw);
      out.savedCount += 1;
      out.savedSheets.push(sn);
    } catch (e) {
      out.errorCount += 1;
      out.errors[sn] = String(e && e.message ? e.message : e);
    }
  });

  out.ok = (out.errorCount === 0);
  return out;
}

function getAllCharacterUiStates(sheetNames) {
  var names = (Array.isArray(sheetNames) && sheetNames.length)
    ? sheetNames
    : ((getPcSheetNames().sheetNames) || []);
  var out = {
    ok: true,
    cellA1: UI_STATE_CELL_A1,
    requestedCount: 0,
    existsCount: 0,
    errorCount: 0,
    sheetNames: names.slice(),
    dataBySheet: {}
  };

  names.forEach(function(rawSheetName) {
    var sn = String(rawSheetName || '').trim();
    if (!sn) return;
    out.requestedCount += 1;
    var result = getCharacterUiState(sn);
    out.dataBySheet[sn] = result;
    if (result && result.ok && result.exists) out.existsCount += 1;
    else if (!result || result.ok === false) out.errorCount += 1;
  });

  out.ok = (out.errorCount === 0);
  return out;
}

function colToA1_(col) {
  var n = Number(col) || 0;
  var s = '';
  while (n > 0) {
    var mod = (n - 1) % 26;
    s = String.fromCharCode(65 + mod) + s;
    n = Math.floor((n - 1) / 26);
  }
  return s || 'A';
}

function rangeA1_(rowStart, colStart, rowEnd, colEnd) {
  return colToA1_(colStart) + rowStart + ':' + colToA1_(colEnd) + rowEnd;
}

function getCharacterVersionRanges_() {
  // Prefer a few broader slices over sparse spot cells so we don't miss edits that
  // affect parsed data or stable IDs, while still avoiding a full 193x37 read.
  return [
    rangeA1_(8, 1, 17, SHEET_COLS),
    rangeA1_(CELL.STAT_ROW, 1, SKILL.END_ROW, SHEET_COLS),
    rangeA1_(TABLE_RANGE.WEAPONS.start, 1, TABLE_RANGE.WEAPONS.end, 25),
    rangeA1_(TABLE_RANGE.ARMORS.start, 1, TABLE_RANGE.ARMORS.end, 21),
    rangeA1_(TABLE_RANGE.VEHICLES.start, 1, TABLE_RANGE.VEHICLES.end, 25),
    rangeA1_(TABLE_RANGE.EXTRA_EFFECTS.start, 1, TABLE_RANGE.EXTRA_EFFECTS.end, EFFECT_COLS.EFFECT),
    rangeA1_(TABLE_RANGE.EFFECTS.start, 1, TABLE_RANGE.EFFECTS.end, EFFECT_COLS.EFFECT)
  ];
}

function readCharacterVersionValues_(ws) {
  return ws.getRangeList(getCharacterVersionRanges_()).getRanges().map(function(r) {
    return r.getValues();
  });
}

function readCharacterRangeValues_(ws) {
  return ws.getRange(1, 1, SHEET_ROWS, SHEET_COLS).getValues();
}

function buildCharacterVersionToken_(ws) {
  var raw = JSON.stringify(readCharacterVersionValues_(ws) || []);
  return String(ws.getSheetId()) + ':cmp:' + md5Hex_(raw);
}

function md5Hex_(raw) {
  var digest = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, String(raw || ''), Utilities.Charset.UTF_8);
  return digest.map(function(b) {
    var v = (b < 0 ? b + 256 : b).toString(16);
    return (v.length === 1 ? '0' : '') + v;
  }).join('');
}

function getCharacterData(sheetName, forceRefresh) {
  var sn = String(sheetName || '').trim();
  if (!sn) return { error: '\uc2dc\ud2b8 \uc774\ub984 \uc5c6\uc74c' };
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var ws = ss.getSheetByName(sn);
  if (!ws) return { error: '\uc2dc\ud2b8 \uc5c6\uc74c: ' + sn };

  var version = buildCharacterVersionToken_(ws);
  var cacheKey = 'dx3:data:v' + CACHE_VERSION + ':' + sn;
  var cache = CacheService.getScriptCache();
  if (!forceRefresh) {
    var cached = cache.get(cacheKey);
    if (cached) {
      try {
        var parsed = JSON.parse(cached);
        if (parsed && parsed._version === version) {
          if (!parsed._spreadsheetId) parsed._spreadsheetId = ss.getId();
          return parsed;
        }
      } catch (_) {}
    }
  }

  var values = readCharacterRangeValues_(ws);
  var data = {};
  function cell(r, c) {
    return values[r - 1][c - 1];
  }

  // Basic
  data.name = String(cell(CELL.NAME[0], CELL.NAME[1]) || cell(CELL.NAME_FALLBACK[0], CELL.NAME_FALLBACK[1]) || sheetName).trim();
  data.hp = cell(CELL.HP[0], CELL.HP[1]);
  data.erosion = Number(cell(CELL.EROSION[0], CELL.EROSION[1])) || 0;
  data.initiative = cell(CELL.INITIATIVE[0], CELL.INITIATIVE[1]);
  data.combatMove = cell(CELL.COMBAT_MOVE[0], CELL.COMBAT_MOVE[1]);
  data.fullMove = cell(CELL.FULL_MOVE[0], CELL.FULL_MOVE[1]);

  // Stats
  data.statValues = {};
  Object.keys(CELL.STAT_COLS).forEach(function(k) {
    data.statValues[k] = Number(cell(CELL.STAT_ROW, CELL.STAT_COLS[k])) || 0;
  });

  // Skills
  data.skills = {};
  SKILL.CONFIGS.forEach(function(c) {
    data.skills[c.key] = [];
    for (var r = SKILL.START_ROW; r <= SKILL.END_ROW; r++) {
      data.skills[c.key].push({
        name: String(cell(r, c.nameCol) || '').trim(),
        value: Number(cell(r, c.valCol)) || 0
      });
    }
  });

  // Items
  data.weapons = readItemTable(values, TABLE_RANGE.WEAPONS.start, TABLE_RANGE.WEAPONS.end, {
    '\ubb34\uae30\uba85': 2,
    '\uc885\ubcc4': 8,
    '\uae30\ub2a5': 10,
    '\uc0ac\uac70\ub9ac': 12,
    '\uba85\uc911': 14,
    '\uacf5\uaca9\ub825': 17,
    '\uac00\ub4dc\uce58': 19,
    '\uc0c1\ube44': 21,
    '\uacbd\ud5d8\uc810 \uad6c\ub9e4': 23,
    '\ud574\uc124': 25
  }, '\ubb34\uae30\uba85', 'weapon', [1]);

  data.armors = readItemTable(values, TABLE_RANGE.ARMORS.start, TABLE_RANGE.ARMORS.end, {
    '\ubc29\uc5b4\uad6c\uba85': 2,
    '\uc885\ubcc4': 8,
    '\ub2f7\uc9c0': 10,
    '\ud589\ub3d9': 12,
    '\uc7a5\uac11': 14,
    '\uc0c1\ube44': 17,
    '\uacbd\ud5d8\uc810 \uad6c\ub9e4': 19,
    '\ud574\uc124': 21
  }, '\ubc29\uc5b4\uad6c\uba85', 'armor', [1]);

  data.vehicles = readItemTable(values, TABLE_RANGE.VEHICLES.start, TABLE_RANGE.VEHICLES.end, {
    '\ube44\ud074\uba85': 2,
    '\uc885\ubcc4': 8,
    '\uae30\ub2a5': 10,
    '\uacf5\uaca9\ub825': 12,
    '\ud589\ub3d9': 14,
    '\uc7a5\uac11': 17,
    '\uc774\ub3d9': 19,
    '\uc0c1\ube44': 21,
    '\uacbd\ud5d8\uc810 \uad6c\ub9e4': 23,
    '\ud574\uc124': 25
  }, '\ube44\ud074\uba85', 'vehicle', [1]);

  // Effects
  data.extraEffects = readEffectTable(values, TABLE_RANGE.EXTRA_EFFECTS.start, TABLE_RANGE.EXTRA_EFFECTS.end, 'ex', [1, 2]);
  data.effects = readEffectTable(values, TABLE_RANGE.EFFECTS.start, TABLE_RANGE.EFFECTS.end, 'ef', [1, 2]);
  data._spreadsheetId = ss.getId();
  data._version = version;

  try { cache.put(cacheKey, JSON.stringify(data), 86400); } catch (_) {}
  return data;
}

function clearCharacterDataCache(sheetName) {
  var sn = String(sheetName || '').trim();
  var cache = CacheService.getScriptCache();
  var keysToRemove = ['dx3:data:' + sn];
  for (var v = 2; v <= CACHE_VERSION; v++) {
    keysToRemove.push('dx3:data:v' + v + ':' + sn);
  }
  keysToRemove.forEach(function(k) { cache.remove(k); });
  return true;
}

function normalizeStableId_(raw) {
  var s = String(raw == null ? '' : raw).trim();
  if (!s) return '';
  // Keep IDs deterministic and safe for DOM keys/localStorage object keys.
  if (!/^[A-Za-z0-9._:-]{1,64}$/.test(s)) return '';
  // Ignore plain numeric values (often row numbers), require at least one letter.
  if (!/[A-Za-z]/.test(s)) return '';
  return s.toLowerCase();
}

function readStableIdFromRow_(rowValues, idCols) {
  var cols = Array.isArray(idCols) ? idCols : [];
  for (var i = 0; i < cols.length; i++) {
    var col = Number(cols[i]) || 0;
    if (col <= 0) continue;
    var v = rowValues[col - 1];
    var id = normalizeStableId_(v);
    if (id) return id;
  }
  return '';
}

function dedupeKey_(baseKey, keySeen) {
  var b = String(baseKey || '').trim();
  if (!b) return '';
  keySeen[b] = (keySeen[b] || 0) + 1;
  if (keySeen[b] === 1) return b;
  return b + '-dup' + keySeen[b];
}

function readItemTable(values, startRow, endRow, colMap, nameKey, keyPrefix, idCols) {
  var items = [];
  var legacySeen = {};
  var keySeen = {};
  for (var r = startRow; r <= endRow; r++) {
    var row = values[r - 1] || [];
    var item = {};
    Object.keys(colMap).forEach(function(field) {
      var v = row[colMap[field] - 1];
      item[field] = (v !== null && v !== '') ? v : '';
    });
    if (!String(item[nameKey] || '').trim()) continue;
    var sig = [
      String(item[nameKey] || '').trim(),
      String(item['\uc885\ubcc4'] || '').trim()
    ].join('|');
    var hash = md5Hex_(sig).slice(0, 12);
    legacySeen[hash] = (legacySeen[hash] || 0) + 1;
    var prefix = String(keyPrefix || 'item');
    var legacyKey = prefix + '-' + hash + '-' + legacySeen[hash];
    var stableId = readStableIdFromRow_(row, idCols);
    if (stableId) {
      item._key = dedupeKey_(prefix + '-id-' + stableId, keySeen);
      item._legacyKey = legacyKey;
    } else {
      item._key = dedupeKey_(legacyKey, keySeen);
    }
    items.push(item);
  }
  return items;
}

function readEffectTable(values, startRow, endRow, keyPrefix, idCols) {
  var effects = [];
  var legacySeen = {};
  var keySeen = {};
  for (var r = startRow; r <= endRow; r++) {
    var row = values[r - 1] || [];
    var name = row[EFFECT_COLS.NAME - 1];
    if (!name || !String(name).trim()) continue;
    var effect = {
      name: String(name).trim(),
      lv: row[EFFECT_COLS.LV - 1],
      timing: String(row[EFFECT_COLS.TIMING - 1] || '-').trim(),
      feature: String(row[EFFECT_COLS.FEATURE - 1] || '-').trim(),
      diff: String(row[EFFECT_COLS.DIFF - 1] || '-').trim(),
      target: String(row[EFFECT_COLS.TARGET - 1] || '-').trim(),
      range: String(row[EFFECT_COLS.RANGE - 1] || '-').trim(),
      erosion: row[EFFECT_COLS.EROSION - 1],
      limit: String(row[EFFECT_COLS.LIMIT - 1] || '-').trim(),
      effect: String(row[EFFECT_COLS.EFFECT - 1] || '').trim()
    };
    var sig = [
      effect.name,
      effect.timing,
      effect.feature,
      effect.diff,
      effect.target,
      effect.range,
      effect.limit
    ].join('|');
    var hash = md5Hex_(sig).slice(0, 12);
    legacySeen[hash] = (legacySeen[hash] || 0) + 1;
    var prefix = String(keyPrefix || 'ef');
    var legacyKey = prefix + '-' + hash + '-' + legacySeen[hash];
    var stableId = readStableIdFromRow_(row, idCols);
    if (stableId) {
      effect._key = dedupeKey_(prefix + '-id-' + stableId, keySeen);
      effect._legacyKey = legacyKey;
    } else {
      effect._key = dedupeKey_(legacyKey, keySeen);
    }
    effects.push(effect);
  }
  return effects;
}

3. 파일 옆에 있는 +버튼을 누르고 HTML을 누르고 Sidebar이라는 이름의 파일을 추가해주세요.

Plain text
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
:root{
  --bg:#060606;
  --panel:#111111;
  --panel-2:#181818;
  --panel-3:#202020;
  --line:#3a3a3a;
  --line-soft:#2b2f35;
  --line-strong:#4a515b;
  --text:#f2f2f2;
  --muted:#a0a0a0;
  --accent:#8f0000;
  --accent-strong:#cf0000;
  --accent-soft:#2b0000;
  --radius-sm:4px;
  --radius-md:6px;
  --radius-lg:10px;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Noto Sans KR','Meiryo',sans-serif;font-size:14px;background:var(--bg);color:var(--text);line-height:1.45;height:100vh;display:flex;flex-direction:column}
::-webkit-scrollbar{width:5px}
::-webkit-scrollbar-track{background:var(--panel)}
::-webkit-scrollbar-thumb{background:#5a5a5a;border-radius:3px}


#hdr{background:#000;padding:8px 10px;border-top:1px solid var(--line-soft);border-bottom:1px solid var(--line-soft);display:flex;flex-direction:column;align-items:stretch;gap:6px;flex-shrink:0}
#pc-bar{display:flex;gap:6px;align-items:center;flex:1 1 auto;min-width:0;width:100%}
#pc-sel{flex:1;max-width:none;background:#d7d7d7;color:#111;border:1px solid #8f8f8f;padding:6px 8px;border-radius:3px;font-size:14px;outline:none;min-width:0}
#btn-clear-cache{display:inline-flex;align-items:center;justify-content:center;background:#2a2a2a;color:#ddd;border:1px solid #4a4a4a;padding:0;border-radius:3px;cursor:pointer;width:30px;height:30px;position:relative;-webkit-user-select:none;user-select:none}
#btn-clear-cache:hover{border-color:#777;color:#fff}
#btn-clear-cache.refreshing{border-color:#7a7a2a;color:#ffffd2}
#btn-clear-cache svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
#btn-clear-cache .lp-ring{position:absolute;inset:0;border-radius:3px;border:2px solid transparent;pointer-events:none;transition:none}
#btn-clear-cache.lp-active .lp-ring{border-color:var(--accent-strong);animation:lp-fill 1.5s linear forwards}
@keyframes lp-fill{0%{clip-path:inset(0 100% 0 0)}100%{clip-path:inset(0 0 0 0)}}
#btn-master-mode{display:inline-flex;align-items:center;justify-content:center;background:#2a2a2a;color:#ddd;border:1px solid #4a4a4a;padding:0;border-radius:3px;cursor:pointer;width:30px;height:30px;font-size:12px;font-weight:700;letter-spacing:.2px}
#btn-master-mode:hover{border-color:#777;color:#fff}
#btn-master-mode.on{border-color:#7a2a2a;color:#ffd2d2;background:#2a0000}
#btn-master-mode svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
#pc-master-actions{display:none;align-items:stretch;justify-content:stretch;gap:6px;width:100%}
.master-bulk-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;background:#2a2a2a;color:#ddd;border:1px solid #4a4a4a;padding:0 10px;border-radius:3px;cursor:pointer;width:auto;height:32px;flex:1 1 0;min-width:0}
.master-bulk-btn:hover{border-color:#777;color:#fff}
.master-bulk-btn.busy{border-color:#7a7a2a;color:#ffffd2}
.master-bulk-btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.master-bulk-btn .lbl{font-size:11px;font-weight:700;letter-spacing:.2px;white-space:nowrap}
#btn-help{display:inline-flex;align-items:center;justify-content:center;background:#2a2a2a;color:#aaa;border:1px solid #4a4a4a;padding:0;border-radius:50%;cursor:pointer;width:30px;height:30px;font-size:13px;font-weight:700;flex-shrink:0}
#btn-help:hover{border-color:#777;color:#fff}
/* old help-overlay CSS removed — replaced by spotlight tour */
#pc-master-tabs{display:none;flex:1 1 auto;min-width:0;gap:4px;overflow-x:auto;overflow-y:hidden;padding:0;align-items:center;scrollbar-width:thin;scrollbar-color:#5a5a5a var(--panel)}
#pc-master-tabs::-webkit-scrollbar{height:4px}
#pc-master-tabs::-webkit-scrollbar-track{background:var(--panel)}
#pc-master-tabs::-webkit-scrollbar-thumb{background:#5a5a5a;border-radius:3px}
.pc-tab{display:inline-flex;align-items:center;justify-content:center;white-space:nowrap;background:var(--panel);color:#c7c7c7;border:1px solid var(--line-soft);border-radius:3px;font-size:11px;padding:4px 8px;cursor:pointer}
.pc-tab:hover{border-color:var(--line-strong);color:#fff}
.pc-tab.on{border-color:#7a2a2a;color:#ffd2d2;background:#2a0000}
.pc-tab.err,.pc-tab.no-data{opacity:0.45;cursor:not-allowed;text-decoration:line-through}
.pc-tab.err{color:#e07070;border-color:#5a2020}


#body{display:flex;flex-direction:column;flex:1;overflow-y:auto;gap:8px;padding:8px}
#left,#right{width:100%;padding:0}
#left{padding-bottom:12px}
#right-empty{padding:14px 10px !important;font-size:11px !important}
#toast-stack{position:fixed;left:10px;right:10px;bottom:12px;display:flex;flex-direction:column;gap:6px;pointer-events:none;z-index:9000}
.toast{max-width:100%;background:rgba(18,18,18,.96);color:#f2f2f2;border:1px solid var(--line-strong);border-left:3px solid var(--accent-strong);border-radius:var(--radius-md);padding:8px 10px;font-size:11px;line-height:1.45;white-space:pre-line;box-shadow:0 10px 28px rgba(0,0,0,.45);opacity:0;transform:translateY(8px);transition:opacity .18s ease,transform .18s ease}
.toast.on{opacity:1;transform:translateY(0)}
.toast-success{border-left-color:#2f8f2f}
.toast-error{border-left-color:#b13b3b}
.toast-info{border-left-color:#6c747e}
#loading-overlay{position:fixed;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(0,0,0,.58);opacity:0;visibility:hidden;pointer-events:none;transition:opacity .18s ease,visibility 0s linear .18s;z-index:9500}
#loading-overlay.on{opacity:1;visibility:visible;pointer-events:auto;transition-delay:0s;backdrop-filter:blur(2px)}
.loading-panel{min-width:220px;max-width:calc(100vw - 32px);background:rgba(18,18,18,.96);border:1px solid var(--line-strong);border-radius:var(--radius-lg);padding:16px 18px;text-align:center;box-shadow:0 14px 36px rgba(0,0,0,.5)}
.loading-panel .spin{margin:0 auto 8px}
.loading-title{font-size:12px;font-weight:700;color:#fff}
.loading-desc{margin-top:4px;font-size:11px;color:#b8b8b8;line-height:1.45}


.spin{width:28px;height:28px;border:2px solid #444;border-top-color:var(--accent-strong);border-radius:50%;animation:spin .7s linear infinite;margin:0 auto 10px}
@keyframes spin{to{transform:rotate(360deg)}}


.c-card{background:var(--panel-2);border:1px solid var(--line-soft);border-radius:var(--radius-lg);padding:8px 10px;margin-bottom:8px}
.c-head{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}
.c-name-wrap{display:inline-flex;align-items:center;gap:6px;min-width:0}
.c-name{font-size:14px;font-weight:700;color:#fff;margin-bottom:8px}
.c-head .c-name{margin-bottom:0}
.char-loading{display:none;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0}
.char-loading.on{display:inline-flex}
.char-loading .spin{width:14px;height:14px;border-width:2px;border-color:#3a3a3a;border-top-color:var(--accent-strong);margin:0}
.c-actions{display:flex;align-items:center;gap:4px;flex-shrink:0}
.icon-btn{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:var(--panel);border:1px solid var(--line-soft);border-radius:var(--radius-sm);color:#d7d7d7;cursor:pointer}
.icon-btn:hover{border-color:var(--line-strong);color:#fff}
.icon-btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}


.badge-row{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px}
.bdg{padding:2px 7px;border-radius:10px;font-size:9px;font-weight:700}
.b-dp{background:#2a0000;color:#ffb2b2}
.b-el{background:#13222c;color:#dff4ff;border:1px solid #2d4d5f}
.b-ini{background:#1f2a14;color:#e8ffd8;border:1px solid #4a6a34}
.b-move{background:#1a1a1a;color:#e2e2e2;border:1px solid #4a4a4a}


.er-row{display:flex;align-items:center;gap:5px;background:var(--panel);border:1px solid var(--line-soft);border-radius:var(--radius-md);padding:5px 8px;margin-bottom:8px}
.er-row label{font-size:9px;color:var(--muted);white-space:nowrap}
#inp-er{width:58px;background:#0a0a0a;color:#fff;border:1px solid var(--accent);padding:2px 5px;border-radius:4px;font-size:16px;font-weight:700;text-align:center;outline:none}
.er-pct{font-size:10px;color:#8a8a8a}
.er-stage{font-size:10px;font-weight:700;padding:2px 6px;border-radius:8px}
.er-stat-tgl{margin-left:auto;flex-shrink:0}
.er-ignore-box{margin-bottom:8px;padding:7px 8px;background:var(--panel);border:1px solid var(--line-soft);border-radius:var(--radius-md)}
.er-ignore-title{font-size:10px;color:var(--muted);font-weight:700;letter-spacing:.2px;margin-bottom:6px}
.er-ignore-list{display:grid;gap:4px}
.er-ignore-opt{display:inline-flex;align-items:center;gap:5px;font-size:10px;color:#d6d6d6}
.er-ignore-opt input{width:13px;height:13px;accent-color:var(--accent-strong);cursor:pointer;flex-shrink:0}


.stat-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:3px;margin-bottom:8px}
.stat-wrap{margin-bottom:8px}
.stat-all-tgl{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;background:var(--panel);border:1px solid var(--line-soft);color:var(--muted);border-radius:var(--radius-sm);cursor:pointer}
.stat-all-tgl:hover{border-color:var(--line-strong);color:#fff}
.stat-all-tgl svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s ease}
.stat-all-tgl.open svg{transform:rotate(180deg)}
.s-card{background:var(--panel);border:1px solid var(--line-soft);border-radius:var(--radius-md);padding:4px 5px}
.s-head{font-size:10px;font-weight:700;color:#ff6c6c;border-bottom:1px solid var(--line-soft);padding-bottom:3px;margin-bottom:3px;display:flex;justify-content:space-between;align-items:baseline}
.s-head .sv{font-size:14px;font-weight:700;color:#fff}
.stat-wrap.collapsed .stat-grid{display:none}
.sk-row{display:flex;justify-content:space-between;padding:1px 0;font-size:9px}
.sk-n{color:var(--muted);max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sk-v{color:#ffd3d3;font-weight:700}


.param-grid{display:grid;grid-template-columns:1fr;gap:4px}
.p-cell{display:grid;grid-template-columns:56px minmax(0,1fr);align-items:center;column-gap:5px}
.p-cell label{font-size:10px;color:#b6b6b6;display:block;margin-bottom:0}
.p-cell select{width:100%;background:#d7d7d7;color:#111;border:1px solid #8f8f8f;padding:3px 5px;border-radius:3px;font-size:11px;font-weight:600;outline:none;cursor:pointer}
.p-cell select:focus{border-color:var(--accent)}
.p-cell input[type="text"]{width:100%;background:#d7d7d7;color:#111;border:1px solid #8f8f8f;padding:3px 5px;border-radius:3px;font-size:11px;font-weight:600;outline:none}
.p-cell input[type="text"]:focus{border-color:var(--accent)}
.ccombo{position:relative}
.ccombo-btn{width:100%;background:#d7d7d7;color:#111;border:1px solid #8f8f8f;padding:3px 18px 3px 5px;border-radius:3px;font-size:11px;font-weight:600;outline:none;cursor:pointer;text-align:left;position:relative}
.ccombo-btn::after{content:'';position:absolute;right:5px;top:50%;width:10px;height:10px;transform:translateY(-50%);background-repeat:no-repeat;background-size:10px 10px;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E")}
.ccombo.open .ccombo-btn{border-color:var(--accent)}
.ccombo-list{display:none;position:absolute;left:0;right:0;top:calc(100% + 4px);background:#d7d7d7;border:1px solid #8f8f8f;border-radius:3px;overflow:hidden;z-index:50;box-shadow:0 6px 20px rgba(0,0,0,.45)}
.ccombo.open .ccombo-list{display:block}
.ccombo-opt{padding:4px 5px;font-size:11px;color:#111;cursor:pointer}
.ccombo-opt:hover{background:#c8c8c8}
.ccombo-opt.on{background:#b8b8b8;color:#111}
.p-cell select,.p-cell .ccombo{grid-column:2}


.tabs{display:flex;gap:1px;border-bottom:1px solid var(--line);margin-bottom:6px}
.tab{flex:1;padding:6px 4px;font-size:12px;font-weight:400;background:none;border:none;color:#7a7a7a;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px}
.tab.on{color:#fff;border-bottom-color:var(--accent)}
.tp{display:none}
.tp.on{display:block}


.sub-lbl{font-size:11px;color:#9a9a9a;padding:5px 2px 4px}
.ef-item{background:var(--panel-2);border:1px solid var(--line-soft);border-radius:var(--radius-md);margin-bottom:3px;overflow:hidden}
.ef-item.on{border-color:#5d3333;background:var(--accent-soft)}
.ef-hd{display:flex;align-items:center;gap:5px;padding:6px 8px;cursor:pointer}
.ef-hd:hover{background:rgba(207,0,0,.08)}
.ef-chk{width:14px;height:14px;cursor:pointer;accent-color:var(--accent-strong);flex-shrink:0}
.ef-nm{font-size:12px;font-weight:400;color:#fff;flex:1;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}
.ef-lv{font-size:11px;color:#ffd7d7;white-space:nowrap;background:#2a1313;border:1px solid #5a2d2d;padding:1px 5px;border-radius:8px}
.ef-tm{font-size:10px;color:#fff;background:#2b2b2b;border:1px solid #474747;padding:1px 5px;border-radius:8px;white-space:nowrap}
.ef-er{font-size:11px;color:#ffc0c0;font-weight:700;white-space:nowrap;background:#2a1010;border:1px solid #6a2b2b;padding:1px 5px;border-radius:8px}
.ef-dtl{padding:0 9px 0 24px;border-top:1px solid var(--line);max-height:0;overflow:hidden;opacity:0;transition:max-height .25s ease,padding .25s ease,opacity .2s ease}
.ef-dtl.open{max-height:2000px;padding:6px 9px 8px 24px;opacity:1}
.ef-tags{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:4px}
.ttag{font-size:9px;background:var(--panel);border:1px solid var(--line);color:#b7b7b7;padding:2px 6px;border-radius:4px}
.ttag span{color:#fff;font-weight:600}
.ttag-btn{cursor:pointer;display:inline-flex;align-items:center;gap:5px}
.ttag-btn:hover{border-color:var(--line-strong);color:#fff}
.ttag-btn.on{border-color:#7a2a2a;color:#ffd2d2;background:#2a0000}
.ttag-chk{display:inline-flex;align-items:center;justify-content:center;width:11px;height:11px;border:1px solid currentColor;border-radius:2px;flex-shrink:0}
.ttag-chk-mark{width:6px;height:3px;border-left:2px solid transparent;border-bottom:2px solid transparent;transform:rotate(-45deg) translateY(-1px)}
.ttag-btn.on .ttag-chk-mark{border-color:currentColor}
.ef-txt{font-size:11px;color:#d0d0d0;line-height:1.5}
.ef-text-editor{margin-top:4px;background:var(--panel);border:1px solid var(--line-soft);border-radius:var(--radius-md);padding:5px}
.ef-text-head{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:4px}
.ef-text-title{font-size:10px;color:#a6a6a6}
.ef-text-reset{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0;background:var(--panel-2);color:#ddd;border:1px solid var(--line-soft);border-radius:var(--radius-sm);cursor:pointer}
.ef-text-reset:hover{border-color:var(--line-strong);color:#fff}
.ef-text-reset.on{border-color:#7a2a2a;color:#ffd2d2;background:#2a0000}
.ef-text-reset svg{width:12px;height:12px;stroke:none;fill:currentColor}
.ef-textarea{width:100%;min-height:88px;resize:vertical;background:#0a0a0a;color:#f0f0f0;border:1px solid var(--line-soft);border-radius:var(--radius-sm);padding:6px 7px;font-size:11px;line-height:1.45}
.ef-textarea:focus{outline:none;border-color:var(--accent)}
.ef-preview,.ef-manual{margin-top:4px;background:var(--panel);border:1px solid var(--line-soft);border-radius:var(--radius-md);padding:5px}
.ef-preview-title,.ef-manual-title{font-size:10px;color:#a6a6a6;margin-bottom:5px}
.ef-preview-empty,.ef-man-empty{font-size:10px;color:#8a8a8a}
.ef-preview-row{font-size:12px;color:#dddddd;line-height:1.45}
.ef-auto-cut{margin-left:6px;display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;background:var(--panel);color:#cfcfcf;border:1px solid var(--line-soft);border-radius:999px;font-size:11px;line-height:1;padding:0;cursor:pointer;vertical-align:middle}
.ef-auto-cut:hover{border-color:#7a2a2a;color:#fff;background:#2a0000}
.ef-auto-cut.on{border-color:#7a2a2a;color:#ffbcbc;background:#2a0000}
.ef-prev-hd{display:flex;align-items:center;justify-content:space-between;gap:6px}
.ef-prev-actions{display:flex;align-items:center;gap:4px}
.ef-prev-tgl{background:none;border:none;color:#c7c7c7;font-size:10px;cursor:pointer;padding:0;text-align:left}
.ef-prev-tgl:hover{color:#fff}
.ef-prev-refresh{display:inline-flex;align-items:center;justify-content:center;width:18px;height:18px;background:var(--panel);border:1px solid var(--line-soft);border-radius:var(--radius-sm);color:#c7c7c7;cursor:pointer}
.ef-prev-refresh:hover{border-color:var(--line-strong);color:#fff}
.ef-prev-refresh svg{width:11px;height:11px;stroke:none;fill:currentColor}
.ef-prev-body{margin-top:4px}
.ef-prev-body.off{display:none}
.ef-cond-tags{display:flex;flex-wrap:wrap;gap:3px;margin-bottom:4px}
.ttag-cond{border-color:#4a3a1a;color:#c8a84a;background:#1a1200}
.ttag-cond{display:inline-flex;align-items:center;gap:4px}
.ttag-cond-hide{display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;border:none;background:none;color:inherit;padding:0;cursor:pointer;font-size:11px;line-height:1;opacity:.75}
.ttag-cond-hide:hover{opacity:1;color:#fff}
.ttag-special{border-color:#1a3a4a;color:#4ac8c8;background:#001a1a;display:inline-flex;align-items:center;gap:4px}
.ttag-special-hide{display:inline-flex;align-items:center;justify-content:center;width:12px;height:12px;border:none;background:none;color:inherit;padding:0;cursor:pointer;font-size:11px;line-height:1;opacity:.75;margin-left:0}
.ttag-special-hide:hover{opacity:1;color:#fff}
.ef-special-tags{display:flex;flex-wrap:wrap;gap:3px;margin-top:4px}
.ttag-manual{border-color:#2a3a1a;color:#8fc86a;background:#0a1a00}
.ef-mtag-val-none{flex:1}
.ef-mtag-field{flex:1;min-width:0;background:#d7d7d7;color:#111;border:1px solid #8f8f8f;border-radius:3px;padding:4px 5px;font-size:11px;font-weight:600}
.ef-mtag-val{flex:1;min-width:0;background:#0a0a0a;color:#fff;border:1px solid var(--line);border-radius:3px;padding:4px 5px;font-size:11px}
.ef-mtag-val:disabled{opacity:.45;cursor:not-allowed}
.ef-mtag-label,.ef-mtag-pill{display:flex;align-items:center;min-height:24px;border-radius:3px;padding:4px 5px;font-size:11px}
.ef-mtag-label{background:#d7d7d7;color:#111;border:1px solid #8f8f8f;font-weight:600}
.ef-mtag-pill{background:#0a0a0a;color:#f0f0f0;border:1px solid var(--line)}
.ef-mtag-del{background:#2a0000;color:#ffc6c6;border:1px solid #5a1a1a;border-radius:3px;font-size:11px;line-height:1;padding:4px;cursor:pointer}
.ef-mtag-del:hover{background:#3a0000;color:#fff}
.ef-man-hd{display:flex;align-items:center;justify-content:space-between;gap:6px}
.ef-man-tgl{background:none;border:none;color:#c7c7c7;font-size:10px;cursor:pointer;padding:0;text-align:left}
.ef-man-tgl:hover{color:#fff}
.ef-man-tgl,.ef-prev-tgl{display:inline-flex;align-items:center;gap:4px}
.txt-ico{display:inline-flex;align-items:center;justify-content:center}
.txt-ico svg{width:10px;height:10px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;transition:transform .15s ease}
.ef-man-tgl.open .txt-ico svg,.ef-prev-tgl.open .txt-ico svg{transform:rotate(180deg)}
.ef-man-body{margin-top:4px}
.ef-man-body.off{display:none}
.ef-man-row{display:grid;grid-template-columns:86px minmax(0,1fr) 20px;gap:4px;align-items:center;margin-bottom:4px}
.ef-man-row:last-child{margin-bottom:0}
.ef-man-field{width:100%;background:#d7d7d7;color:#111;border:1px solid #8f8f8f;border-radius:3px;padding:4px 5px;font-size:11px;font-weight:600}
.ef-man-kind{width:100%;background:#d7d7d7;color:#111;border:1px solid #8f8f8f;border-radius:3px;padding:4px 5px;font-size:11px;font-weight:600}
.ef-man-expr{width:100%;background:#0a0a0a;color:#fff;border:1px solid var(--line);border-radius:3px;padding:4px 5px;font-size:11px}
.ef-man-del{background:#2a0000;color:#ffc6c6;border:1px solid #5a1a1a;border-radius:3px;font-size:11px;line-height:1;padding:4px;cursor:pointer}
.ef-man-del:hover{background:#3a0000;color:#fff}
.ef-man-rule-row{grid-template-columns:minmax(0,1fr) minmax(0,1fr) 20px}
.extra-adj{margin:6px 0 4px;padding:7px;background:var(--panel);border:1px solid var(--line-soft);border-radius:var(--radius-md)}
.extra-adj-top{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:6px}
.extra-adj-hd{font-size:10px;color:var(--muted);font-weight:700;letter-spacing:.2px}
.extra-adj-top-actions{display:flex;align-items:center;gap:4px}
.extra-adj-head-btn,.extra-adj-row-add,.extra-adj-del{display:inline-flex;align-items:center;justify-content:center;width:24px;height:24px;padding:0;background:var(--panel-2);color:#ddd;border:1px solid var(--line-soft);border-radius:var(--radius-sm);cursor:pointer}
.extra-adj-head-btn:hover,.extra-adj-row-add:hover,.extra-adj-del:hover{border-color:var(--line-strong);color:#fff}
.extra-adj-head-btn svg,.extra-adj-row-add svg,.extra-adj-del svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.extra-adj-section-toggle svg,.extra-adj-hide svg{transition:transform .15s ease}
.extra-adj-section-toggle.open svg,.extra-adj-hide.open svg{transform:rotate(180deg)}
.extra-adj-group-del,.extra-adj-del{border-color:#5a1a1a;color:#ffc6c6;background:#2a0000}
.extra-adj-group-del:hover,.extra-adj-del:hover{background:#3a0000;color:#fff}
.extra-adj-body{display:grid;gap:6px}
.extra-adj-body.off{display:none}
.extra-adj-group{background:#101010;border:1px solid var(--line-soft);border-radius:var(--radius-sm);padding:6px}
.extra-adj-group-head{display:grid;grid-template-columns:minmax(0,1fr) 24px 24px 24px;gap:5px;align-items:center}
.extra-adj-name-wrap{display:flex;align-items:center;gap:5px;min-width:0}
.extra-adj-on{display:inline-flex;align-items:center;justify-content:center;width:16px}
.extra-adj-chk{width:14px;height:14px;cursor:pointer;accent-color:var(--accent-strong)}
.extra-adj-name{flex:1;min-width:0;background:#d7d7d7;color:#111;border:1px solid #8f8f8f;border-radius:3px;padding:4px 6px;font-size:11px;font-weight:600}
.extra-adj-name:focus{border-color:var(--accent);outline:none}
.extra-adj-group-body{margin-top:6px;display:grid;gap:5px;padding-left:21px}
.extra-adj-group-body.off{display:none}
.extra-adj-row{display:grid;grid-template-columns:78px minmax(0,1fr) 24px;gap:5px;align-items:center}
.extra-adj-field{width:100%;background:#d7d7d7;color:#111;border:1px solid #8f8f8f;border-radius:3px;padding:4px 5px;font-size:10px;font-weight:600}
.extra-adj-val{width:100%;background:#0a0a0a;color:#fff;border:1px solid var(--line);border-radius:3px;padding:4px 6px;font-size:11px}
.extra-adj-empty{font-size:10px;color:#8f939a;padding:4px 0}


.itbl-wrap{overflow-x:auto;margin-bottom:6px}
.itbl{width:100%;border-collapse:collapse;font-size:11px}
.itbl th{background:var(--panel);color:#a0a0a0;padding:4px 5px;text-align:left;font-weight:600;font-size:10px;white-space:nowrap;border-bottom:1px solid var(--line)}
.itbl td{padding:4px 5px;border-bottom:1px solid var(--line-soft);color:#efefef;vertical-align:top}
.itbl tr:hover td{background:var(--panel-3)}
.itbl-none{color:#8a8a8a;text-align:center;padding:9px;font-size:11px}
.eq-chk{width:14px;height:14px;cursor:pointer;accent-color:var(--accent-strong);appearance:auto;-webkit-appearance:checkbox;display:inline-block;vertical-align:middle}
.itbl td:first-child{width:30px;text-align:center}
.ceq-row{display:flex;align-items:center;gap:3px;padding:2px 0}
.ceq-input{background:#0a0a0a;color:#fff;border:1px solid var(--line);border-radius:3px;padding:3px 4px;font-size:10px;min-width:0;flex:1}
.ceq-input.ceq-name{flex:2}
.ceq-input.ceq-num{flex:0 0 36px;text-align:center}
.ceq-del{background:none;border:1px solid #5a1a1a;color:#ffc6c6;border-radius:3px;width:20px;height:20px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;flex-shrink:0}
.ceq-del:hover{background:#3a0000;color:#fff}
.ceq-del svg{width:10px;height:10px;stroke:currentColor;fill:none;stroke-width:2.5;stroke-linecap:round}
.ceq-add-wrap{padding:3px 0}
.ceq-add{background:none;border:1px dashed var(--line);color:#8a8a8a;border-radius:3px;padding:3px 8px;font-size:10px;cursor:pointer;display:inline-flex;align-items:center;gap:3px}
.ceq-add:hover{border-color:var(--accent);color:var(--accent)}
.ceq-add svg{width:10px;height:10px;stroke:currentColor;fill:none;stroke-width:2.5;stroke-linecap:round}


#calc-panel{background:var(--panel-2);border:1px solid var(--line-soft);border-radius:var(--radius-lg);padding:6px 6px;margin-bottom:8px}
.calc-hd{font-size:14px;font-weight:700;color:#fff;margin-bottom:8px}
.calc-empty{font-size:11px;color:#8a8a8a;text-align:center;padding:14px 0}

.dice-box{background:var(--panel);border-radius:var(--radius-lg);padding:8px;margin-bottom:6px;text-align:center;border:1px solid var(--line-soft)}
.dice-top{display:flex;align-items:center;justify-content:center;gap:6px}
.dice-formula{font-size:20px;font-weight:700;color:#ff4c4c;letter-spacing:1px;word-break:break-all}
.dice-sub{font-size:11px;color:#a7a7a7;margin-top:3px}
.dice-meta{margin-top:3px}
.dice-copy{background:var(--panel-2);color:#ddd;border:1px solid var(--line-soft);border-radius:var(--radius-sm);font-size:10px;padding:2px 7px;cursor:pointer;white-space:nowrap}
.dice-copy:hover{border-color:var(--line-strong);color:#fff}
.dice-copy{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0}
.dice-copy svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.dice-copy.done{border-color:#2f8f2f;color:#b8ffb8}

.r-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:4px;margin-bottom:6px}
.r-grid.compact{grid-template-columns:repeat(4,minmax(0,1fr))}
.r-grid.full{grid-template-columns:repeat(4,minmax(0,1fr))}
.r-grid.full .r-blk{grid-column:1/-1}
.r-blk{background:var(--panel);border:1px solid var(--line-soft);border-radius:var(--radius-md);padding:6px 5px;text-align:center}
.r-grid.compact .r-blk{padding:5px 4px}
.r-lbl{font-size:9px;color:#a7a7a7;margin-bottom:2px;line-height:1.25}
.r-val{font-size:14px;font-weight:700;color:#fff}
.r-grid.compact .r-val{font-size:13px}
.rv-gold{color:#ff6c6c} .rv-green{color:#fff} .rv-red{color:#ff7a7a} .rv-blue{color:#ff9f9f}

.ptags{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px}
.ptag{background:var(--panel);border:1px solid var(--line-soft);border-radius:var(--radius-sm);padding:4px 8px;font-size:10px;color:#b7b7b7}
.ptag span{color:#fff;font-weight:600}

.sel-list{border-top:1px solid var(--line);padding-top:8px}
.sel-ef{margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--line-soft)}
.sel-ef:last-child{border-bottom:none;margin-bottom:0}
.sel-nm{font-size:12px;font-weight:700;color:#ff7f7f}
.sel-lv{font-size:10px;color:#b5b5b5;margin-left:5px}
.sel-er{font-size:10px;color:#ff8f8f;margin-left:5px}
.sel-txt{font-size:11px;color:#c7c7c7;margin-top:3px;line-height:1.5}
.coco-box{margin-top:8px;padding-top:8px;border-top:1px solid var(--line)}
.coco-top{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px}
.coco-top-actions{display:inline-flex;align-items:center;gap:4px}
.coco-hd{font-size:10px;color:#9a9a9a}
.coco-ta{width:100%;min-height:62px;resize:vertical;background:#0a0a0a;color:#f0f0f0;border:1px solid var(--line-soft);border-radius:var(--radius-md);padding:6px 7px;font-size:11px;line-height:1.45}
.coco-copy{background:var(--panel);color:#ddd;border:1px solid var(--line-soft);border-radius:var(--radius-sm);font-size:11px;padding:3px 9px;cursor:pointer}
.coco-copy:hover{border-color:var(--line-strong);color:#fff}
.coco-copy.done{border-color:#2f8f2f;color:#b8ffb8}
.coco-copy{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0}
.coco-copy svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.coco-opts{margin-top:6px;padding:6px;background:var(--panel);border:1px solid var(--line-soft);border-radius:var(--radius-md)}
.coco-opt-row{display:flex;align-items:center;gap:6px;margin-bottom:4px}
.coco-opt-row:last-child{margin-bottom:0}
.coco-opt-right{margin-left:auto;display:inline-flex;align-items:center;gap:6px}
.coco-opt-chk{width:13px;height:13px;accent-color:var(--accent-strong);cursor:pointer}
.coco-opt-lbl{font-size:10px;color:#d6d6d6}
.coco-sep{width:92px;background:#0a0a0a;color:#fff;border:1px solid var(--line-soft);border-radius:var(--radius-sm);padding:2px 5px;font-size:10px}
.coco-save{display:grid;grid-template-columns:minmax(0,1fr) 34px;gap:6px;margin-top:4px;margin-bottom:4px}
.coco-name{width:100%;background:#0a0a0a;color:#fff;border:1px solid var(--line-soft);border-radius:var(--radius-sm);padding:4px 6px;font-size:11px}
.coco-add{background:var(--panel);color:#ddd;border:1px solid var(--line-soft);border-radius:var(--radius-sm);font-size:14px;line-height:1;cursor:pointer}
.coco-add:hover{border-color:var(--line-strong);color:#fff}
.coco-memo{margin-top:6px;width:100%;min-height:56px;resize:vertical;background:#0a0a0a;color:#fff;border:1px solid var(--line-soft);border-radius:var(--radius-sm);padding:5px 6px;font-size:11px;line-height:1.4}
.coco-list{margin-top:8px;border-top:1px solid var(--line);padding-top:8px}
.coco-item{margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--line-soft)}
.coco-item:last-child{margin-bottom:0;padding-bottom:0;border-bottom:none}
.coco-item-hd{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:4px}
.coco-item-nm{font-size:11px;color:#f0f0f0;font-weight:700}
.coco-item-actions{display:flex;gap:4px}
.coco-item-btn{background:var(--panel);color:#ddd;border:1px solid var(--line-soft);border-radius:var(--radius-sm);font-size:10px;padding:2px 7px;cursor:pointer}
.coco-item-btn:hover{border-color:var(--line-strong);color:#fff}
.coco-item-btn.danger{border-color:#5a1a1a;color:#ffc6c6;background:#2a0000}
.coco-item-btn.danger:hover{background:#3a0000;color:#fff}
.coco-item-btn{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;padding:0}
.coco-item-btn svg{width:12px;height:12px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.coco-item-btn.done{border-color:#2f8f2f;color:#b8ffb8}
.coco-item-ta{width:100%;min-height:48px;resize:vertical;background:#080808;color:#ececec;border:1px solid var(--line-soft);border-radius:var(--radius-sm);padding:5px 6px;font-size:10px;line-height:1.4}

/* ── Spotlight Guide Tour ── */
#tour-overlay{position:fixed;inset:0;z-index:10000;pointer-events:none;opacity:0;transition:opacity .25s ease}
#tour-overlay.on{opacity:1;pointer-events:auto}
#tour-spot{position:absolute;border-radius:6px;box-shadow:0 0 0 9999px rgba(0,0,0,.78);transition:top .3s ease,left .3s ease,width .3s ease,height .3s ease;pointer-events:none;cursor:default;z-index:10001}
#tour-overlay.on #tour-spot{pointer-events:auto}
#tour-overlay.on #tour-tooltip{pointer-events:auto}
#tour-tooltip{position:absolute;z-index:10002;background:var(--panel-2);border:1px solid var(--line-strong);border-radius:var(--radius-lg);padding:14px 16px;width:280px;max-width:calc(100vw - 24px);box-shadow:0 8px 32px rgba(0,0,0,.6);pointer-events:none;transition:top .3s ease,left .3s ease}
#tour-tooltip::before{content:'';position:absolute;width:10px;height:10px;background:var(--panel-2);border:1px solid var(--line-strong);transform:rotate(45deg);z-index:-1}
#tour-tooltip.arrow-top::before{top:-6px;left:24px;border-right:none;border-bottom:none}
#tour-tooltip.arrow-bottom::before{bottom:-6px;left:24px;border-left:none;border-top:none}
#tour-tooltip.arrow-left::before{left:-6px;top:18px;border-top:none;border-right:none}
#tour-tooltip.arrow-right::before{right:-6px;top:18px;border-bottom:none;border-left:none}
.tour-step-badge{display:inline-block;background:var(--accent);color:#fff;font-size:10px;font-weight:700;padding:2px 8px;border-radius:10px;margin-bottom:6px}
.tour-title{font-size:13px;font-weight:700;color:#fff;margin-bottom:6px}
.tour-desc{font-size:12px;color:#ccc;line-height:1.55;margin-bottom:12px}
.tour-desc b{color:#fff;font-weight:700}
.tour-btns{display:flex;gap:6px;align-items:center}
.tour-btn{padding:5px 12px;border-radius:var(--radius-sm);font-size:11px;font-weight:600;cursor:pointer;border:1px solid var(--line);background:var(--panel-3);color:#ccc;transition:background .15s,border-color .15s,color .15s}
.tour-btn:hover{border-color:var(--line-strong);color:#fff}
.tour-btn.primary{background:var(--accent);border-color:var(--accent-strong);color:#fff}
.tour-btn.primary:hover{background:var(--accent-strong)}
.tour-btn-skip{margin-left:auto;background:none;border:none;color:#777;font-size:10px;cursor:pointer;padding:4px 6px}
.tour-btn-skip:hover{color:#bbb}
</style>
</head>
<body>

<div id="hdr">
  <div id="pc-bar">
    <select id="pc-sel"><option value="">-- PC 선택 --</option></select>
    <div id="pc-master-tabs"></div>
    <button id="btn-master-mode" type="button" title="마스터 보기 전환" aria-label="마스터 보기 전환">M</button>
    <button id="btn-clear-cache" type="button" title="클릭: 새로고침 / 길게: 캐시 초기화" aria-label="새로고침">
      <svg viewBox="0 0 24 24" aria-hidden="true">
        <path d="M3 2v6h6"></path>
        <path d="M21 22v-6h-6"></path>
        <path d="M21 11a9 9 0 0 0-15.5-6.4L3 8"></path>
        <path d="M3 13a9 9 0 0 0 15.5 6.4L21 16"></path>
      </svg>
      <span class="lp-ring"></span>
    </button>
    <button id="btn-help" type="button" title="사용법 안내" aria-label="사용법 안내">?</button>
  </div>
  <div id="pc-master-actions">
    <button id="btn-master-load-all" class="master-bulk-btn" type="button" title="마스터 모드 전체 불러오기" aria-label="현재 인식된 모든 PC 상태 불러오기">
      <svg viewBox="0 0 24 24" aria-hidden="true">
        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
        <polyline points="17 8 12 3 7 8"></polyline>
        <line x1="12" y1="3" x2="12" y2="15"></line>
      </svg>
      <span class="lbl">전체 불러오기</span>
    </button>
    <button id="btn-master-save-all" class="master-bulk-btn" type="button" title="마스터 모드 전체 저장" aria-label="현재 인식된 모든 PC 상태 저장">
      <svg viewBox="0 0 24 24" aria-hidden="true">
        <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
        <polyline points="7 10 12 15 17 10"></polyline>
        <line x1="12" y1="15" x2="12" y2="3"></line>
      </svg>
      <span class="lbl">전체 저장</span>
    </button>
  </div>
</div>

<!-- Guide Tour -->
<div id="tour-overlay" role="dialog" aria-modal="true" aria-label="사용법 가이드 투어">
  <div id="tour-spot"></div>
  <div id="tour-tooltip">
    <div class="tour-step-badge" id="tour-badge"></div>
    <div class="tour-title" id="tour-title" role="heading" aria-level="2"></div>
    <div class="tour-desc" id="tour-desc"></div>
    <div class="tour-btns">
      <button type="button" class="tour-btn" id="tour-prev">이전</button>
      <button type="button" class="tour-btn primary" id="tour-next">다음</button>
      <button type="button" class="tour-btn-skip" id="tour-skip">건너뛰기</button>
    </div>
  </div>
</div>

<div id="body">
  
  <div id="left">
    <div id="left-content" style="display:none"></div>
  </div>
  
  <div id="right">
    <div id="right-content" style="display:none">
      <div id="calc-panel">
        <div class="calc-hd">조합 결과</div>
        <div id="calc-result"><div class="calc-empty">이펙트를 체크하거나 기능을 선택하면 결과가 표시됩니다</div></div>
      </div>
    </div>
    <div id="right-empty" style="color:#8a8a8a;text-align:center;padding:14px 10px;font-size:11px">PC를 선택하고 불러오기를 눌러주세요</div>
  </div>
</div>

<div id="toast-stack" aria-live="polite" aria-atomic="true"></div>
<div id="loading-overlay" aria-live="polite" aria-atomic="true" aria-hidden="true">
  <div class="loading-panel" role="status" aria-label="데이터 불러오는 중">
    <div class="spin" aria-hidden="true"></div>
    <div class="loading-title">PC 데이터를 불러오는 중입니다…</div>
    <div class="loading-desc">잠시만 기다려주세요.</div>
  </div>
</div>

<script>
var G   = null;
var SEL = [];
var EQSEL = { weapon: {}, armor: {}, vehicle: {} };
var CUSTOM_EQUIP = { weapon: [], armor: [], vehicle: [] };
var EFFECT_INDEX = {};
var EFFECT_KEY_ALIAS = {};
var EFFECT_MODS = {
  critical: 0,
  criticalFloor: 7,
  hit: 0,
  achievement: 0,
  dice: 0,
  dicePenaltyImmune: false,
  attack: 0,
  guard: 0,
  armor: 0,
  ignoreTargetArmor: false,
  clearFlight: false,
  sameEngageForbidden: false,
  sameEngageAllowed: false,
  reactionForbidden: false,
  dodgeForbidden: false,
  dodgeNoDiceRoll: false,
  guardForbidden: false,
  coverForbidden: false,
  coverAsNoGuard: false,
  initiative: 0,
  hp: 0,
  hpDamage: 0,
  erosion: 0,
  achievementDice: 0,
  initiativeFixedZero: false
};
var STAT_KEYS   = ['physical','sense','mental','social'];
var STAT_LABELS = ['\uc721\uccb4','\uac10\uac01','\uc815\uc2e0','\uc0ac\ud68c'];
var RANGE_OPTIONS  = ['\ubb34\uae30','\uadfc\uc811','\uc2dc\uc57c','5m','\uc528','-'];
var TARGET_OPTIONS = ['\uc790\uc2e0','\ub2e8\uc77c','1\uccb4','2\uccb4','\ubc94\uc704(\uc120\ud0dd)','\ubc94\uc704(\uc804\uccb4)','\uc528','-'];
var MANUAL_FIELD_OPTIONS = [
  { value:'dice',            label:'다이스',            desc:'판정 시 굴리는 다이스 수 보정' },
  { value:'achievement',     label:'달성치',            desc:'명중을 포함한 판정 달성치(합계) 보정' },
  { value:'attack',          label:'공격력',            desc:'메인 프로세스 공격 판정에 더하는 수치' },
  { value:'guard',           label:'가드치',            desc:'가드 판정 시 더하는 수치' },
  { value:'armor',           label:'장갑치',            desc:'받는 대미지를 줄이는 장갑 수치' },
  { value:'initiative',      label:'행동치',            desc:'행동 순서를 결정하는 수치' },
  { value:'hp',              label:'HP 회복',           desc:'HP를 회복하는 양' },
  { value:'hpDamage',        label:'HP 대미지',         desc:'대상에게 가하는 HP 대미지' },
  { value:'erosion',         label:'침식치',            desc:'침식률 변화량' },
  { value:'hpDice',          label:'HP 회복 다이스(D)', desc:'HP 회복량에 더하는 다이스 수' },
  { value:'hpDamageDice',    label:'HP 대미지 다이스(D)', desc:'HP 대미지에 더하는 다이스 수' },
  { value:'critical',        label:'크리티컬치',        desc:'크리티컬 발생 기준 수치 (낮을수록 크리 쉬움)' },
  { value:'attackDice',      label:'공격력(D)',         desc:'공격력에 더하는 다이스 수' },
  { value:'guardDice',       label:'가드치(D)',         desc:'가드치에 더하는 다이스 수' },
  { value:'armorDice',       label:'장갑치(D)',         desc:'장갑치에 더하는 다이스 수' },
  { value:'achievementDice', label:'달성치(D)',         desc:'명중을 포함한 달성치에 더하는 다이스 수' },
  { value:'initDice',        label:'행동치(D)',         desc:'행동치에 더하는 다이스 수' },
  { value:'critDice',        label:'크리티컬치(D)',     desc:'크리티컬치에 더하는 다이스 수' }
];
var MANUAL_FIELD_UI_GROUPS = [
  { value:'manualTag',   label:'태그 추가',   flat:'__customText' },
  { value:'dice',        label:'다이스',      flat:'dice' },
  { value:'achievement', label:'달성치',      flat:'achievement', dice:'achievementDice' },
  { value:'attack',      label:'공격력',      flat:'attack', dice:'attackDice' },
  { value:'guard',       label:'가드치',      flat:'guard', dice:'guardDice' },
  { value:'armor',       label:'장갑치',      flat:'armor', dice:'armorDice' },
  { value:'initiative',  label:'행동치',      flat:'initiative', dice:'initDice' },
  { value:'hp',          label:'HP 회복',     flat:'hp', dice:'hpDice' },
  { value:'hpDamage',    label:'HP 대미지',   flat:'hpDamage', dice:'hpDamageDice' },
  { value:'erosion',     label:'침식치',      flat:'erosion' },
  { value:'critical',    label:'크리티컬치',  flat:'critical', dice:'critDice' }
];
var MANUAL_TAG_OPTIONS = [
  { value:'criticalFloor',       label:'크리 하한',           hasValue:true,  placeholder:'숫자 (예: 7)' },
  { value:'dicePenaltyImmune',   label:'다이스 감소 무효',    hasValue:false },
  { value:'ignoreTargetArmor',   label:'대상 장갑 무시',      hasValue:false },
  { value:'rangeOverride',       label:'사정거리',            hasValue:true,  placeholder:'시야, 근접, 무기 등' },
  { value:'targetOverride',      label:'대상',               hasValue:true,  placeholder:'단일, 범위(선택) 등' },
  { value:'initiativeFixedZero', label:'행동치 0 고정',       hasValue:false },
  { value:'reactionForbidden',   label:'대상 리액션 불가',    hasValue:false },
  { value:'dodgeForbidden',      label:'대상 닷지 불가',      hasValue:false },
  { value:'dodgeNoDiceRoll',     label:'대상 닷지 다이스 롤 불가', hasValue:false },
  { value:'guardForbidden',      label:'대상 가드 불가',      hasValue:false },
  { value:'coverForbidden',      label:'커버링 불가',         hasValue:false },
  { value:'coverAsNoGuard',      label:'커버링 가드 미적용',  hasValue:false },
  { value:'clearFlight',         label:'비행상태 해제',       hasValue:false },
  { value:'sameEngageForbidden', label:'같은 인게이지 불가',  hasValue:false },
  { value:'sameEngageAllowed',   label:'같은 인게이지 가능',  hasValue:false }
];
var MANUAL_TAG_CUSTOM = '__customText';
function getManualTagOption(val) {
  var found = null;
  MANUAL_TAG_OPTIONS.forEach(function(opt) { if (opt.value === val) found = opt; });
  return found;
}
function getManualFieldUiGroup(field) {
  field = normalizeBonusFieldKey(field);
  var found = null;
  MANUAL_FIELD_UI_GROUPS.forEach(function(group) {
    if (group.flat === field || group.dice === field) found = group;
  });
  return found || null;
}
function getManualFieldUiMode(field) {
  field = normalizeBonusFieldKey(field);
  var group = getManualFieldUiGroup(field);
  if (!group) return 'flat';
  return (group.dice && group.dice === field) ? 'dice' : 'flat';
}
function resolveManualFieldUiSelection(groupValue, modeValue) {
  var group = null;
  MANUAL_FIELD_UI_GROUPS.forEach(function(item) {
    if (item.value === groupValue) group = item;
  });
  if (!group) group = MANUAL_FIELD_UI_GROUPS[0] || { flat: 'dice' };
  return (modeValue === 'dice' && group.dice) ? group.dice : group.flat;
}
function getManualCustomTagText(tag) {
  if (!tag || typeof tag !== 'object') return '';
  if (tag.tag !== MANUAL_TAG_CUSTOM) return '';
  return String(tag.value || tag.text || '').trim();
}
function hasManualDiceSuffix(expr) {
  var src = String(expr || '').replace(/\s+/g, '');
  return /D(?:10)?$/i.test(src);
}
function stripManualDiceSuffix(expr) {
  return String(expr || '').replace(/\s*D(?:10)?\s*$/i, '').trim();
}
function ensureManualDiceSuffix(expr) {
  var src = String(expr || '').trim();
  if (!src) return '';
  return hasManualDiceSuffix(src) ? src : (src + 'D');
}
function normalizeManualRuleStorage(field, expr) {
  var nextField = normalizeBonusFieldKey(field || 'dice');
  var nextExpr = String(expr || '');
  if (nextField === MANUAL_TAG_CUSTOM) return { field: nextField, expr: nextExpr };
  var group = getManualFieldUiGroup(nextField);
  if (group && group.dice === nextField) {
    nextField = group.flat || nextField;
    nextExpr = ensureManualDiceSuffix(nextExpr);
  }
  return { field: nextField, expr: nextExpr };
}
function resolveManualRuleField(field, expr) {
  var nextField = normalizeBonusFieldKey(field || 'dice');
  if (nextField === MANUAL_TAG_CUSTOM) return nextField;
  var group = getManualFieldUiGroup(nextField);
  if (!group) return nextField;
  if (hasManualDiceSuffix(expr) && group.dice) return group.dice;
  return group.flat || nextField;
}
function getManualRuleDisplayExpr(field, expr) {
  var nextField = normalizeBonusFieldKey(field || 'dice');
  var nextExpr = String(expr || '');
  var group = getManualFieldUiGroup(nextField);
  if (group && group.dice === nextField) return ensureManualDiceSuffix(nextExpr);
  return nextExpr;
}
function normalizeBonusFieldKey(field) {
  var key = String(field || 'achievement');
  if (key === 'hit') return 'achievement';
  if (key === 'hitDice') return 'achievementDice';
  return key;
}
var EF_CUSTOM = {};
var EF_COND = {};
var EXTRA_ADJ = {
  sectionHidden: false,
  groups: [{ name: '추가 수정치 1', enabled: true, open: true, rows: [{ field: 'achievement', value: '' }] }]
};
var COCO_STATE = { includeEffectText: false, useErosionDiceBonus: true, useErosionEffectLvBonus: true, useItemBonuses: true, memoText: '', memoPre: '', memoSuf: '', draftName: '', combos: [], separatorText: ' | ' };
var CUR_SHEET = '';
var CUR_SPREADSHEET_ID = '';
var PC_SHEETS = [];
var MASTER_MODE = false;
var MASTER_DATA = {};
var MASTER_MODE_KEY = 'dx3cal:masterMode';
var LOAD_SEQ = 0;
var CACHE_PREFIX = 'dx3cal:state:';
var CUR_DATA_VERSION = '';
var IS_LOADING_CHAR = false;
var REFRESH_INFLIGHT = false;
var MASTER_LOAD_ALL_INFLIGHT = false;
var MASTER_SAVE_ALL_INFLIGHT = false;
var LONGPRESS_MS = 1500;
var _longPressTimer = 0;
var _longPressFired = false;
var _recalcTimer = 0;
var _saveTimer = 0;
var _pendingStateSave = null;
var DIRTY = {
  NONE: 0,
  RECALC: 1 << 0,
  PREVIEW: 1 << 1
};
var _dirtyMask = DIRTY.NONE;
var _dirtyPreviewKeys = {};
var _dirtyFlushScheduled = false;
var _dirtyFlushing = false;
var _RE_STAT_CACHE = {};
var _EFFECT_PARSE_CACHE = {};
var _EFFECT_PARSE_CACHE_SIZE = 0;
var _EFFECT_PARSE_CACHE_LIMIT = 1200;
var _TOKEN_VALUE_MAP_CACHE = null;
var _TOKEN_VALUE_MAP_CACHE_KEY = '';
var _SAVED_COCO_TEXT_CACHE = {};
var _SAVED_COCO_TEXT_CACHE_SIZE = 0;
var _SAVED_COCO_TEXT_CACHE_LIMIT = 500;
var CALC_VIEW_STATE = null;
var COND_PATTERNS = [
  { key:'targetFlying', label:'대상이 비행상태', re:/대상.{0,20}?비행상태(?:일|인)\s*경우/i, snippetRe:/대상.{0,20}?비행상태(?:일|인)\s*경우[^.。!\n\r]*/gi },
  { key:'targetNotFlying', label:'대상이 비행상태 아님', re:/대상.{0,20}?비행상태가?\s*아닌\s*경우/i, snippetRe:/대상.{0,20}?비행상태가?\s*아닌\s*경우[^.。!\n\r]*/gi },
  { key:'selfFlying', label:'자신이 비행상태', re:/(자신|이\s*에너미).{0,20}?비행상태(?:인|일)\s*동안/i, snippetRe:/(자신|이\s*에너미).{0,20}?비행상태(?:인|일)\s*동안[^.。!\n\r]*/gi },
  { key:'selfBerserk', label:'자신이 폭주 상태', re:/폭주.{0,16}?동안/i, snippetRe:/폭주.{0,16}?동안[^.。!\n\r]*/gi },
  { key:'selfHidden', label:'자신이 은밀 상태', re:/은밀상태.{0,16}?동안/i, snippetRe:/은밀상태.{0,16}?동안[^.。!\n\r]*/gi },
  { key:'onDealHpDamage', label:'HP 대미지 가한 직후', re:/1점\s*이라도\s*HP\s*대미지.{0,24}?가했을\s*때/i, snippetRe:/1점\s*이라도\s*HP\s*대미지.{0,24}?가했을\s*때[^.。!\n\r]*[.。]\s*그\s*(?:씬|라운드)\s*동안[^.。!\n\r]*/gi },
  { key:'onTakeHpDamage', label:'HP 대미지 받은 직후', re:/1점\s*이라도\s*HP\s*대미지.{0,24}?받았을\s*때/i, snippetRe:/1점\s*이라도\s*HP\s*대미지.{0,24}?받았을\s*때[^.。!\n\r]*[.。]\s*그\s*(?:씬|라운드)\s*동안[^.。!\n\r]*/gi },
  { key:'onTargetAutoEffect', label:'대상이 자동성공 이펙트 사용', re:/대상.{0,24}?자동성공.{0,24}?이펙트.{0,16}?사용했을\s*때/i, snippetRe:/대상.{0,24}?자동성공.{0,24}?이펙트.{0,16}?사용했을\s*때[^.。!\n\r]*/gi }
];

var ICON_SVG = {
  plus: '<svg viewBox="0 0 24 24" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>',
  minus: '<svg viewBox="0 0 24 24" aria-hidden="true"><line x1="5" y1="12" x2="19" y2="12"></line></svg>',
  undo: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256" aria-hidden="true"><path d="M236,144a68.07,68.07,0,0,1-68,68H80a12,12,0,0,1,0-24h88a44,44,0,0,0,0-88H61l27.52,27.51a12,12,0,0,1-17,17l-48-48a12,12,0,0,1,0-17l48-48a12,12,0,1,1,17,17L61,76H168A68.08,68.08,0,0,1,236,144Z"></path></svg>',
  reset: '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" fill="currentColor" viewBox="0 0 256 256" aria-hidden="true"><path d="M228,48V96a12,12,0,0,1-12,12H168a12,12,0,0,1,0-24h19l-7.8-7.8a75.55,75.55,0,0,0-53.32-22.26h-.43A75.49,75.49,0,0,0,72.39,75.57,12,12,0,1,1,55.61,58.41a99.38,99.38,0,0,1,69.87-28.47H126A99.42,99.42,0,0,1,196.2,59.23L204,67V48a12,12,0,0,1,24,0ZM183.61,180.43a75.49,75.49,0,0,1-53.09,21.63h-.43A75.55,75.55,0,0,1,76.77,179.8L69,172H88a12,12,0,0,0,0-24H40a12,12,0,0,0-12,12v48a12,12,0,0,0,24,0V189l7.8,7.8A99.42,99.42,0,0,0,130,226.06h.56a99.38,99.38,0,0,0,69.87-28.47,12,12,0,0,0-16.78-17.16Z"></path></svg>',
  eye: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7S1 12 1 12z"></path><circle cx="12" cy="12" r="3"></circle></svg>',
  eyeOff: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17.94 17.94A10.86 10.86 0 0 1 12 19C5 19 1 12 1 12a21.87 21.87 0 0 1 5.06-5.94"></path><path d="M9.9 4.24A10.94 10.94 0 0 1 12 5c7 0 11 7 11 7a21.79 21.79 0 0 1-2.16 3.19"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>',
  chevronDown: '<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="6 9 12 15 18 9"></polyline></svg>',
  chevronRight: '<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="9 6 15 12 9 18"></polyline></svg>'
};

function showToast(message, kind, durationMs) {
  var host = document.getElementById('toast-stack');
  if (!host) return;
  while (host.firstChild) host.removeChild(host.firstChild);
  var toast = document.createElement('div');
  toast.className = 'toast toast-' + String(kind || 'info');
  toast.setAttribute('role', kind === 'error' ? 'alert' : 'status');
  toast.textContent = String(message || '');
  host.appendChild(toast);
  var hideMs = Math.max(1400, Number(durationMs) || 2400);
  var close = function() {
    if (!toast.parentNode) return;
    toast.classList.remove('on');
    setTimeout(function() {
      if (toast.parentNode) toast.parentNode.removeChild(toast);
    }, 180);
  };
  if (window.requestAnimationFrame) {
    window.requestAnimationFrame(function() { toast.classList.add('on'); });
  } else {
    setTimeout(function() { toast.classList.add('on'); }, 0);
  }
  setTimeout(close, hideMs);
}

function updateLoadingOverlay() {
  var overlay = document.getElementById('loading-overlay');
  if (!overlay) return;
  overlay.classList.toggle('on', IS_LOADING_CHAR);
  overlay.setAttribute('aria-hidden', IS_LOADING_CHAR ? 'false' : 'true');
}

function scheduleDirtyFlush() {
  if (_dirtyFlushScheduled) return;
  _dirtyFlushScheduled = true;
  var run = function() {
    _dirtyFlushScheduled = false;
    flushDirty();
  };
  if (window.requestAnimationFrame) {
    window.requestAnimationFrame(run);
  } else {
    setTimeout(run, 16);
  }
}

function markPreviewDirty(key) {
  _dirtyMask |= DIRTY.PREVIEW;
  if (key) _dirtyPreviewKeys[String(key)] = 1;
  scheduleDirtyFlush();
}

function markRecalcDirty() {
  _dirtyMask |= DIRTY.RECALC;
  scheduleDirtyFlush();
}

function flushDirty() {
  if (_dirtyFlushing) return;
  _dirtyFlushing = true;
  var mask = _dirtyMask;
  var previewKeys = Object.keys(_dirtyPreviewKeys);
  _dirtyMask = DIRTY.NONE;
  _dirtyPreviewKeys = {};

  if (mask & DIRTY.RECALC) {
    recalcCore();
  } else if (mask & DIRTY.PREVIEW) {
    if (!previewKeys.length) refreshAllEffectPreviews();
    else previewKeys.forEach(function(key) { renderEffectPreview(key); });
  }

  _dirtyFlushing = false;
  if (_dirtyMask !== DIRTY.NONE) scheduleDirtyFlush();
}

function recalc() {
  markRecalcDirty();
}

function recalcDebounced() {
  clearTimeout(_recalcTimer);
  _recalcTimer = setTimeout(markRecalcDirty, 50);
}

function snapshotCurrentState(opts) {
  var state = buildCurrentStateObject(opts);
  var snap = cloneJsonSafe(state, null);
  return snap || state;
}

function cancelPendingStateSave() {
  clearTimeout(_saveTimer);
  _saveTimer = 0;
  _pendingStateSave = null;
}

function flushPendingStateSave() {
  clearTimeout(_saveTimer);
  _saveTimer = 0;
  if (!_pendingStateSave) return;
  writeSheetState(_pendingStateSave.sheetName, _pendingStateSave.state);
  _pendingStateSave = null;
}

function saveCurrentStateDebounced() {
  var sheetName = CUR_SHEET || (document.getElementById('pc-sel') || {}).value || '';
  if (!sheetName || !G) return;
  _pendingStateSave = {
    sheetName: sheetName,
    state: snapshotCurrentState()
  };
  clearTimeout(_saveTimer);
  _saveTimer = setTimeout(flushPendingStateSave, 300);
}

function setLoadingState(on) {
  IS_LOADING_CHAR = !!on;
  updateMasterSaveAllButton();
  updateLoadingOverlay();
  var node = document.getElementById('char-loading');
  if (!node) return;
  node.classList.toggle('on', IS_LOADING_CHAR);
}

function extractStatDeltaMulti(txt, keywords, lv) {
  for (var i = 0; i < keywords.length; i++) {
    var v = extractStatDelta(txt, keywords[i], lv);
    if (v !== 0) return v;
  }
  return 0;
}

document.getElementById('pc-sel').addEventListener('change', function() {
  if (this.value) loadChar(this.value);
});
(function() {
  var btn = document.getElementById('btn-clear-cache');
  btn.addEventListener('mousedown', function(e) {
    if (e.button !== 0) return;
    _longPressFired = false;
    btn.classList.add('lp-active');
    _longPressTimer = setTimeout(function() {
      _longPressFired = true;
      btn.classList.remove('lp-active');
      clearCurrentCache();
    }, LONGPRESS_MS);
  });
  btn.addEventListener('mouseup', function() {
    clearTimeout(_longPressTimer);
    btn.classList.remove('lp-active');
    if (!_longPressFired) refreshCharacterData();
  });
  btn.addEventListener('mouseleave', function() {
    clearTimeout(_longPressTimer);
    btn.classList.remove('lp-active');
  });
  // Touch support for mobile
  btn.addEventListener('touchstart', function(e) {
    _longPressFired = false;
    btn.classList.add('lp-active');
    _longPressTimer = setTimeout(function() {
      _longPressFired = true;
      btn.classList.remove('lp-active');
      clearCurrentCache();
    }, LONGPRESS_MS);
  }, { passive: true });
  btn.addEventListener('touchend', function(e) {
    clearTimeout(_longPressTimer);
    btn.classList.remove('lp-active');
    if (!_longPressFired) { e.preventDefault(); refreshCharacterData(); }
  });
  btn.addEventListener('touchcancel', function() {
    clearTimeout(_longPressTimer);
    btn.classList.remove('lp-active');
  });
})();
document.getElementById('btn-master-mode').addEventListener('click', function() {
  setMasterMode(!MASTER_MODE);
});
document.getElementById('btn-master-load-all').addEventListener('click', function() {
  loadAllMasterStatesFromSheetSlots();
});
document.getElementById('btn-master-save-all').addEventListener('click', function() {
  saveAllMasterStatesToSheetSlots();
});

/* ── Spotlight Guide Tour ── */
var GuideTour = (function() {
  // ── 리저렉트 카드 열기/닫기 헬퍼 ──
  function openFirstCard() {
    var first = document.querySelector('#tp-ef .ef-item');
    if (first) {
      var dtl = first.querySelector('.ef-dtl');
      if (dtl && !dtl.classList.contains('open')) { dtl.classList.add('open'); dtl.style.maxHeight = ''; }
    }
    return first;
  }
  function closeFirstCard() {
    var first = document.querySelector('#tp-ef .ef-item');
    if (first) {
      var dtl = first.querySelector('.ef-dtl');
      if (dtl) { dtl.classList.remove('open'); dtl.style.maxHeight = ''; }
    }
  }
  // ── 무기/방어구 탭 열기/닫기 헬퍼 ──
  function switchToTab(tabId) {
    var tab = document.getElementById(tabId);
    if (tab) tab.click();
  }

  var STEPS = [
    // ── 0. 전체 흐름 안내 ──
    {
      target: '#body',
      title: '사용 흐름 안내',
      desc: '이 계산기의 기본 사용 순서입니다.\n\n<b>① PC 선택</b> → 시트에서 데이터 불러오기\n<b>② 기능 선택</b> → 판정 기준 능력치 결정\n<b>③ 이펙트 체크</b> → 왼쪽에서 사용할 이펙트 선택\n<b>④ 결과 확인</b> → 오른쪽에 최종 수치 표시\n\n왼쪽에서 선택하면 오른쪽에 결과가 자동 갱신됩니다.'
    },

    // ── 1. 헤더 영역 ──
    { target: '#pc-sel', title: 'PC 선택', desc: '상단 드롭다운에서 캐릭터 시트를 선택하세요.\n선택하면 시트에서 캐릭터 데이터를 자동으로 불러옵니다.\n\n<b>시트 인식 조건</b>\n• 시트 이름에 <b>PC</b>가 포함되어야 합니다\n• NPC는 이름에 <b>NPC</b>로 구분하면 편합니다.\n  (예: "PC1", "적(NPC)","이름[PC2]")' },
    { target: '#btn-clear-cache', title: '새로고침 / 캐시 초기화', desc: '• <b>클릭</b> — 캐릭터 데이터를 새로 불러옵니다\n• <b>길게 누르기</b> — 캐시를 완전 초기화 후 재로드\n시트를 수정했는데 반영이 안 되면 길게 눌러보세요.' },
    { target: '#btn-master-mode', title: '마스터 모드', desc: '모든 PC를 탭으로 한 번에 열어 빠르게 전환할 수 있습니다.\nGM이 여러 PC를 동시에 관리할 때 유용합니다.' },
    { target: '#btn-master-load-all', title: '마스터 전체 불러오기', desc: '마스터 모드에서 각 PC 시트 저장 슬롯의 상태를 한 번에 불러옵니다.\n현재 로컬 상태를 시트 저장 상태로 덮어쓸 때 사용합니다.\n\n개별 PC를 하나씩 열지 않아도 전체 상태를 한꺼번에 복원할 수 있습니다.' },
    { target: '#btn-master-save-all', title: '마스터 전체 저장', desc: '마스터 모드에서 현재 열려 있는 각 PC 상태를 시트 저장 슬롯에 한 번에 저장합니다.\n로컬 상태가 없는 PC는 기존 저장값을 유지하거나 기본값으로 채워 저장합니다.\n\n세션 종료 전 전체 백업용으로 쓰기 좋습니다.' },
    { target: function() { return document.querySelector('#pc-master-tabs .pc-tab') || document.getElementById('pc-master-tabs'); }, title: '마스터 PC 탭', desc: '마스터 모드에서는 각 PC가 탭으로 표시됩니다.\n탭을 누르면 해당 PC 상태로 즉시 전환됩니다.\n\n데이터가 없거나 오류가 있는 시트는 비활성 상태로 표시됩니다.' },

    // ── 2. 캐릭터 정보 ──
    {
      target: function() { return document.getElementById('btn-export-state') || document.querySelector('.c-actions') || document.querySelector('.c-head'); },
      needsG: true,
      title: '시트 저장',
      desc: '캐릭터 이름 옆의 <b>↓ 저장</b> 버튼입니다.\n현재 상태를 시트 저장 슬롯에 저장합니다.\n\n세션 도중 계산 상태를 직접 백업하고 싶을 때 사용하세요.'
    },
    {
      target: function() { return document.getElementById('btn-import-state') || document.querySelector('.c-actions') || document.querySelector('.c-head'); },
      needsG: true,
      title: '시트 불러오기',
      desc: '캐릭터 이름 옆의 <b>↑ 불러오기</b> 버튼입니다.\n시트 저장 슬롯에 저장해 둔 상태를 현재 화면으로 복원합니다.\n\n시트를 닫았다가 다시 열었을 때 이전 조합 상태를 되살릴 수 있습니다.'
    },
    { target: function() { return document.getElementById('inp-er'); }, needsG: true, title: '침식률 입력', desc: '침식률을 입력하면 아래 수치가 자동 갱신됩니다.\n• <b>DICE+</b> — 침식률에 따른 판정 다이스 보너스\n• <b>EFF.LV+</b> — 이펙트 레벨 보너스\n  (100%→+1, 160%→+2, 220%→+3)' },
    { target: function() { return document.getElementById('er-badges'); }, needsG: true, title: '배지 & 능력치', desc: '침식률에 연동되는 요약 배지입니다.\n• DICE+, EFF.LV+, 행동, 전투 이동, 전력 이동\n• 우측 <b>▼ 버튼</b>으로 능력치 패널을 펼치거나 접습니다.' },
    // ── 3. 기능 & 파라미터 ──
    { target: function() { return document.getElementById('sel-feat'); }, needsG: true, title: '기능 선택', desc: '판정에 사용할 능력치 또는 기능기술을 선택합니다.\n선택하면 기본 다이스 수가 결정되어 우측 결과 패널의 <b>다이스 수식(xDX)</b>이 바로 갱신됩니다.' },
    { target: function() { return document.getElementById('sel-statmod'); }, needsG: true, title: '능력치 변동', desc: '기능은 그대로 두되, 판정 기준 능력치만 다른 것으로 바꿀 때 사용합니다.\n예: 〈RC〉 기술이지만 【감각】으로 판정하는 경우' },
    {
      target: function() {
        return document.getElementById('sel-range') || document.getElementById('sel-target');
      },
      needsG: true,
      title: '사정거리 / 대상',
      desc: '이펙트를 체크하면 자동으로 최적 값이 제안됩니다.\n직접 수정할 수도 있고, 목록에 없는 값도 자유롭게 작성할 수 있습니다.'
    },

    // ── 4. 이펙트 탭 ──
    { target: function() { return document.querySelector('.tabs'); }, needsG: true, title: '이펙트 · 무기 · 비클 탭', desc: '3개의 탭으로 구성되어 있습니다.\n• <b>이펙트</b> — 이펙트 목록 & 추가 수정치\n• <b>무기/방어구</b> — 장비 선택 & 직접 추가\n• <b>비클</b> — 비클 선택 & 직접 추가' },

    // ── 5. 이펙트 카드 상세 ──
    {
      target: function() { return openFirstCard(); },
      needsG: true,
      title: '이펙트 카드 상세',
      desc: '첫 번째 이펙트 카드를 예시로 보겠습니다.\n• <b>체크박스</b> — 이펙트를 조합에 추가/제거\n• <b>헤더 클릭</b> — 상세 카드 펼치기/접기\n• 헤더에 <b>Lv</b>, <b>타이밍</b>, <b>침식률(+N)</b>이 표시됩니다.',
      onLeave: closeFirstCard
    },
    {
      target: function() { openFirstCard(); var f = document.querySelector('#tp-ef .ef-item'); return f ? f.querySelector('.ef-preview') : null; },
      needsG: true,
      title: '자동 인식 (파싱 결과)',
      desc: '이펙트 텍스트에서 수치를 자동 추출합니다.\n• <b>달성치·공격력·크리티컬·다이스</b> 등을 인식\n• 잘못된 항목은 <b>× 제외</b> 버튼으로 끄기\n• <b>특수 태그</b> — 사정거리·크리 하한·닷지 불가 등 자동 인식\n• <b>조건부 태그</b> — 조건부 발동 문구 표시\n• <b>LV+ 예외</b> — 침식률 레벨업을 무시하는 토글',
      onLeave: closeFirstCard
    },
    {
      target: function() { openFirstCard(); var f = document.querySelector('#tp-ef .ef-item'); return f ? f.querySelector('.ef-manual') : null; },
      needsG: true,
      title: '수동 보정 (이펙트별)',
      desc: '자동 파싱으로 잡히지 않는 수치를 직접 입력합니다.\n• 필드(달성치, 공격력 등)를 선택하고 식을 입력\n• 식 예시: <b>LV*10</b>, <b>LV+2</b>, <b>3D</b>, <b>LV+1D</b>\n• 입력값 끝에 <b>D</b>를 붙이면 해당 항목의 다이스 보정으로 처리됩니다.\n• 필드를 <b>태그 추가</b>로 바꾸면 자유 텍스트 태그를 같은 줄에서 직접 추가할 수 있습니다.',
      onLeave: closeFirstCard
    },
    {
      target: function() { openFirstCard(); return document.querySelector('.ef-text-editor') || document.querySelector('#tp-ef .ef-item .ef-dtl'); },
      needsG: true,
      title: '이펙트 내용 편집',
      desc: '이펙트 원문을 직접 수정하면 파싱이 다시 실행됩니다.\n• 텍스트를 고쳐 원하는 파싱 결과를 유도\n• <b>↩ 버튼</b>으로 언제든 원문 복원 가능',
      onLeave: closeFirstCard
    },

    // ── 6. 추가 수정치 그룹 ──
    {
      target: function() { return document.querySelector('#extra-adj-wrap .extra-adj') || document.getElementById('extra-adj-wrap'); },
      needsG: true,
      title: '추가 수정치 그룹',
      desc: '이펙트 선택과 <b>무관하게</b> 항상 합산되는 보정치입니다.\n• <b>+ 그룹 추가</b> 버튼으로 복수 그룹 생성\n• 그룹별 이름 변경 · 접기/펼치기 · 삭제\n• 그룹 단위로 <b>ON/OFF 토글</b> 가능\n• 값 끝에 <b>D</b>를 붙이면 해당 항목의 다이스 보정으로 처리\n아이템 보정, 버프, 디버프 등을 여기서 관리하세요.'
    },

    // ── 7. 무기/방어구 탭 ──
    {
      target: '#tp-wp',
      onEnter: function() { switchToTab('tab-wp'); },
      needsG: true,
      title: '무기 & 방어구',
      desc: '시트에 등록된 무기·방어구 목록입니다.\n• <b>체크박스</b>로 장비를 켜면 명중·공격력·가드치 등이 결과에 합산\n• 무기 별로 <b>종별·기능·사거리</b>가 표시됩니다\n• 방어구는 <b>닷지·행동·장갑</b>이 표시됩니다',
      onLeave: function() { switchToTab('tab-ef'); }
    },
    {
      target: function() { return document.querySelector('#tp-wp .ceq-add') || document.getElementById('tp-wp'); },
      onEnter: function() { switchToTab('tab-wp'); },
      needsG: true,
      title: '장비 직접 추가',
      desc: '시트에 없는 장비를 직접 입력할 수 있습니다.\n• 각 탭 하단의 <b>+ 추가</b> 버튼 클릭\n• 무기명·종별·명중·공격력·가드치 등을 입력\n• 추가한 장비도 체크박스로 ON/OFF 가능',
      onLeave: function() { switchToTab('tab-ef'); }
    },

    // ── 8. 비클 탭 ──
    {
      target: '#tp-vh',
      onEnter: function() { switchToTab('tab-vh'); },
      needsG: true,
      title: '비클 (탈것)',
      desc: '시트에 등록된 비클 목록입니다.\n• 체크하면 <b>공격력·행동·장갑·이동</b>이 결과에 합산\n• 하단 <b>+ 추가</b>로 직접 입력도 가능합니다.',
      onLeave: function() { switchToTab('tab-ef'); }
    },

    // ── 9. 결과 패널 ──
    {
      target: function() { return document.querySelector('.dice-box') || document.getElementById('calc-panel'); },
      needsG: true,
      title: '다이스 수식',
      desc: '기능 선택 + 이펙트 체크 + 무기 선택의 결과로\n최종 <b>다이스 수식(예: 8DX8+5)</b>이 표시됩니다.\n• 옆 <b>복사 버튼</b>을 누르면 클립보드에 즉시 복사됩니다.\n• 사정/대상과 각종 보정이 반영된 최종 판정식입니다.'
    },
    { target: function() { return document.getElementById('er-ignore-box'); }, needsG: true, title: '미반영 옵션', desc: '조합 결과 상단의 침식치 바로 아래에서 계산 제외 옵션을 켜고 끌 수 있습니다.\n• <b>침식 다이스 미반영</b>\n• <b>침식 이펙트 레벨 미반영</b>\n• <b>모든 아이템 보정 미반영</b>\n체크하면 결과값과 코코포리아 복사용 텍스트에서 함께 빠집니다.' },
    {
      target: function() { return document.getElementById('calc-panel') || document.getElementById('right-content'); },
      needsG: true,
      title: '조합 결과 상세',
      desc: '모든 수치가 합산되어 한눈에 보입니다.\n• <b>크리티컬치</b> · <b>달성치</b>\n• <b>다이스</b> · <b>공격력</b> · <b>가드치</b>\n• <b>장갑치</b> · <b>행동치</b> · <b>HP</b>\n이펙트·무기·추가 수정치가 모두 반영된 최종 값입니다.\n\n결과를 확인했으면, 아래 코코포리아 영역에서 복사할 수 있습니다.'
    },

    // ── 10. 코코포리아 & 콤보 ──
    {
      target: function() { return document.querySelector('.coco-opts') || document.getElementById('calc-panel'); },
      needsG: true,
      title: '코코포리아 복사',
      desc: '이펙트 조합 요약을 코코포리아 형식으로 자동 생성합니다.\n• <b>구분자</b> — 항목 사이 문자 변경 가능\n• <b>효과내용 포함</b> — 각 이펙트 본문도 출력\n• <b>침식률 보정</b> — 끄면 침식률에 의한 다이스·이펙트LV 보정을 제외\n  (마이너/셋업 콤보 등에 유용)\n• <b>메모 기호</b> — 괄호 스타일 선택\n• <b>메모</b> — 추가 메모를 텍스트 끝에 첨부'
    },
    {
      target: function() { return document.querySelector('.coco-save') || document.getElementById('calc-coco-list'); },
      needsG: true,
      title: '콤보 저장',
      desc: '자주 쓰는 이펙트 조합을 이름 붙여 저장합니다.\n• 콤보 이름 입력 후 <b>+</b> 버튼으로 저장\n• 현재 침식률 기준으로 텍스트를 재계산\n• 복사 버튼으로 즉시 클립보드 복사\n• 텍스트 영역을 직접 편집할 수도 있습니다'
    }
  ];

  var overlay = document.getElementById('tour-overlay');
  var spot = document.getElementById('tour-spot');
  var tooltip = document.getElementById('tour-tooltip');
  var badge = document.getElementById('tour-badge');
  var titleEl = document.getElementById('tour-title');
  var descEl = document.getElementById('tour-desc');
  var prevBtn = document.getElementById('tour-prev');
  var nextBtn = document.getElementById('tour-next');
  var skipBtn = document.getElementById('tour-skip');
  var btnHelp = document.getElementById('btn-help');

  var cur = 0;
  var active = false;
  var resizeTimer = null;

  function getTargetEl(step) {
    var t = step.target;
    if (typeof t === 'function') return t();
    return document.querySelector(t);
  }

  function isVisible(el) {
    if (!el) return false;
    if (el.offsetParent === null && getComputedStyle(el).position !== 'fixed') return false;
    var r = el.getBoundingClientRect();
    return r.width > 0 && r.height > 0;
  }

  function getVisibleSteps() {
    var result = [];
    for (var i = 0; i < STEPS.length; i++) {
      var step = STEPS[i];
      // needsG 스텝: target 함수를 호출하지 않고 G 존재 여부만 확인
      // (target 함수 안에서 openFirstCard/switchToTab 등 부작용 방지, 단계 수 안정화)
      if (step.needsG) {
        if (G) result.push({ idx: i, step: step, el: null });
        continue;
      }
      var el = getTargetEl(step);
      if (isVisible(el)) result.push({ idx: i, step: step, el: el });
    }
    return result;
  }

  function positionSpot(el) {
    var pad = 6;
    var r = el.getBoundingClientRect();
    spot.style.top = (r.top - pad) + 'px';
    spot.style.left = (r.left - pad) + 'px';
    spot.style.width = (r.width + pad * 2) + 'px';
    spot.style.height = (r.height + pad * 2) + 'px';
  }

  function positionTooltip(el) {
    var gap = 12;
    var r = el.getBoundingClientRect();
    var tw = tooltip.offsetWidth || 280;
    var th = tooltip.offsetHeight || 150;
    var vw = window.innerWidth;
    var vh = window.innerHeight;

    tooltip.className = '';
    var top, left, arrow;

    // 대상이 뷰포트보다 크거나 아래쪽이 화면 밖인 경우 → 상단 고정 배치
    if (r.height > vh * 0.7 || r.bottom > vh) {
      top = Math.max(8, Math.min(r.top, vh * 0.15));
      left = Math.max(8, Math.min(r.left, vw - tw - 8));
      arrow = 'arrow-top';
    }
    // 아래에 공간이 충분하면 아래에 배치
    else if (r.bottom + gap + th < vh) {
      top = r.bottom + gap;
      left = Math.max(8, Math.min(r.left, vw - tw - 8));
      arrow = 'arrow-top';
    }
    // 위에 공간이 충분하면 위에 배치
    else if (r.top - gap - th > 0) {
      top = r.top - gap - th;
      left = Math.max(8, Math.min(r.left, vw - tw - 8));
      arrow = 'arrow-bottom';
    }
    // 그 외에는 뷰포트 상단 근처에 배치
    else {
      top = Math.max(8, Math.min(r.top + gap, vh * 0.15));
      left = Math.max(8, Math.min(r.left, vw - tw - 8));
      arrow = 'arrow-top';
    }

    // tooltip이 뷰포트 하단을 넘지 않도록 최종 클램프
    if (top + th > vh - 8) {
      top = vh - th - 8;
    }

    tooltip.style.top = top + 'px';
    tooltip.style.left = left + 'px';
    tooltip.classList.add(arrow);
  }

  var prevStepData = null;

  function callOnLeave() {
    if (prevStepData && typeof prevStepData.onLeave === 'function') {
      try { prevStepData.onLeave(); } catch(_) {}
    }
  }

  function showStep() {
    var visible = getVisibleSteps();
    if (!visible.length) { end(); return; }

    // 이전 스텝 정리
    callOnLeave();

    // cur가 범위를 벗어나면 보정
    if (cur < 0) cur = 0;
    if (cur >= visible.length) cur = visible.length - 1;

    var item = visible[cur];
    prevStepData = item.step;
    var step = item.step;
    var total = visible.length;

    // onEnter 콜백 (탭 전환 등) — target 해석 전에 실행
    if (typeof step.onEnter === 'function') {
      try { step.onEnter(); } catch(_) {}
    }

    // needsG 스텝은 onEnter 후 실제 target을 해석
    var el = item.el || getTargetEl(step);
    if (!el || !isVisible(el)) {
      // target을 찾을 수 없으면 스킵
      if (cur < visible.length - 1) { cur++; showStep(); }
      else end();
      return;
    }

    // 뷰포트 밖이면 스크롤 (#body가 실제 스크롤 컨테이너)
    var scrollContainer = document.getElementById('body');
    var r = el.getBoundingClientRect();
    var containerRect = scrollContainer.getBoundingClientRect();
    var needsScroll = r.top < containerRect.top || r.bottom > containerRect.bottom;

    badge.textContent = (cur + 1) + ' / ' + total;
    titleEl.textContent = step.title;
    descEl.innerHTML = (step.desc || '').replace(/\n/g, '<br>');

    if (needsScroll) {
      // 스크롤 중에는 spot/tooltip을 화면 밖에 숨김
      spot.style.top = '-9999px';
      tooltip.style.top = '-9999px';
      var elTop = r.top - containerRect.top + scrollContainer.scrollTop;
      var targetScroll;
      // 대상이 컨테이너보다 크면 상단 맞춤 (tooltip 공간 확보)
      if (r.height > scrollContainer.clientHeight * 0.6) {
        targetScroll = elTop - 8;
      } else {
        targetScroll = elTop - scrollContainer.clientHeight / 2 + r.height / 2;
      }
      scrollContainer.scrollTo({ top: Math.max(0, targetScroll), behavior: 'smooth' });
      setTimeout(function() { positionSpot(el); positionTooltip(el); }, 400);
    } else {
      positionSpot(el);
      positionTooltip(el);
    }

    // 버튼 상태
    prevBtn.style.display = cur === 0 ? 'none' : '';
    if (cur === total - 1) {
      nextBtn.textContent = '종료';
    } else {
      nextBtn.textContent = '다음';
    }

    nextBtn.focus();
  }

  // 투어 시작 전 저장해둘 원래 상태
  var _tourPrevFeat = '';
  var _tourPrevSelKeys = [];
  var _tourAutoSelected = false;

  var _tourPendingStart = false;

  function prepareTourDemo() {
    _tourAutoSelected = false;
    // PC가 로드되지 않았으면 첫 번째 PC 자동 선택 후 로드 완료 대기
    if (!G) {
      var sel = document.getElementById('pc-sel');
      if (sel && sel.options.length > 1) {
        _tourPendingStart = true; // 로드 완료 후 투어 재개
        sel.selectedIndex = 1;
        sel.dispatchEvent(new Event('change'));
      }
      return false; // 아직 준비 안 됨
    }
    _tourPendingStart = false;
    // 기능이 선택되지 않았으면 첫 번째 기능 자동 선택
    var featEl = document.getElementById('sel-feat');
    if (featEl) {
      _tourPrevFeat = featEl.value;
      if (!featEl.value || featEl.value === 'NONE') {
        for (var i = 0; i < featEl.options.length; i++) {
          if (featEl.options[i].value !== 'NONE') {
            featEl.value = featEl.options[i].value;
            _tourAutoSelected = true;
            break;
          }
        }
      }
    }
    // 이펙트가 하나도 선택되지 않았으면 첫 번째 일반 이펙트 자동 체크
    // (추가 이펙트 prefix='ex' 가 아닌, 일반 이펙트 prefix='ef' 를 선택)
    _tourPrevSelKeys = getSelectedEffectKeys();
    if (!SEL.length) {
      var firstChk = document.querySelector('.ef-chk[data-key^="ef-"]')
                  || document.querySelector('.ef-chk');
      if (firstChk) {
        firstChk.checked = true;
        onChk(firstChk.getAttribute('data-key') || '', firstChk);
        _tourAutoSelected = true;
      }
    }
    if (_tourAutoSelected) {
      recalc();
    }
    return true; // 준비 완료
  }

  function restoreTourDemo() {
    if (!_tourAutoSelected) return;
    // 기능 복원
    var featEl = document.getElementById('sel-feat');
    if (featEl && _tourPrevFeat !== undefined) {
      featEl.value = _tourPrevFeat;
    }
    // 이펙트 선택 복원
    applySelectedEffects(_tourPrevSelKeys);
    recalc();
    _tourAutoSelected = false;
  }

  function start() {
    cur = 0;
    active = true;
    var ready = prepareTourDemo();
    if (!ready) return;
    overlay.classList.add('on');
    showStep();
    document.addEventListener('keydown', onKey);
    window.addEventListener('resize', onResize);
    document.getElementById('body').addEventListener('scroll', onScroll, true);
  }

  function end() {
    // 오버레이 제거를 최우선으로 — 에러가 나도 클릭 차단 방지
    active = false;
    overlay.classList.remove('on');
    document.removeEventListener('keydown', onKey);
    window.removeEventListener('resize', onResize);
    try { document.getElementById('body').removeEventListener('scroll', onScroll, true); } catch(_) {}
    try { callOnLeave(); } catch(_) {}
    prevStepData = null;
    try { restoreTourDemo(); } catch(_) {}
    try { localStorage.setItem('dx3cal-tour-done', '1'); } catch(_) {}
    try { btnHelp.focus(); } catch(_) {}
  }

  function next() {
    var visible = getVisibleSteps();
    if (cur >= visible.length - 1) { end(); return; }
    cur++;
    showStep();
  }

  function prev() {
    if (cur <= 0) return;
    cur--;
    showStep();
  }

  function refresh() {
    if (!active) return;
    var visible = getVisibleSteps();
    if (!visible.length) { end(); return; }
    if (cur >= visible.length) cur = visible.length - 1;
    var step = visible[cur].step;
    var el = visible[cur].el || getTargetEl(step);
    if (!el) return;
    var inHeader = false;
    try {
      var hdr = document.getElementById('hdr');
      inHeader = hdr && hdr.contains(el);
    } catch(_) {}
    if (inHeader) return;
    // body 내부 요소: 스크롤 중 즉시 재배치 (디바운스 짧게)
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(function() {
      if (!active) return;
      var freshEl = visible[cur] ? (visible[cur].el || getTargetEl(visible[cur].step)) : null;
      if (!freshEl) return;
      positionSpot(freshEl);
      positionTooltip(freshEl);
    }, 30);
  }

  function onKey(e) {
    if (!active) return;
    if (e.key === 'Escape') { end(); e.preventDefault(); return; }
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { next(); e.preventDefault(); return; }
    if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { prev(); e.preventDefault(); return; }
    // 포커스 트랩
    if (e.key === 'Tab') {
      var btns = tooltip.querySelectorAll('button');
      var first = btns[0], last = btns[btns.length - 1];
      if (e.shiftKey) {
        if (document.activeElement === first) { last.focus(); e.preventDefault(); }
      } else {
        if (document.activeElement === last) { first.focus(); e.preventDefault(); }
      }
    }
  }

  function onResize() {
    clearTimeout(resizeTimer);
    resizeTimer = setTimeout(function() {
      if (!active) return;
      var visible = getVisibleSteps();
      if (!visible.length) { end(); return; }
      if (cur >= visible.length) cur = visible.length - 1;
      var step = visible[cur].step;
      var el = visible[cur].el || getTargetEl(step);
      if (el) { positionSpot(el); positionTooltip(el); }
    }, 100);
  }

  function onScroll() { refresh(); }

  // 스포트라이트 클릭: 실제 하이라이트 영역 안이면 차단, 어두운 그림자 영역이면 투어 종료
  spot.addEventListener('click', function(e) {
    var r = spot.getBoundingClientRect();
    if (e.clientX >= r.left && e.clientX <= r.right &&
        e.clientY >= r.top && e.clientY <= r.bottom) {
      e.stopPropagation();
    } else {
      end();
    }
  });

  // 버튼 이벤트
  nextBtn.addEventListener('click', function(e) { e.stopPropagation(); next(); });
  prevBtn.addEventListener('click', function(e) { e.stopPropagation(); prev(); });
  skipBtn.addEventListener('click', function(e) { e.stopPropagation(); end(); });
  // 어두운 영역(overlay 자체) 클릭 시에만 투어 종료
  overlay.addEventListener('click', function(e) {
    if (e.target === overlay) end();
  });

  // ? 버튼으로 투어 시작
  btnHelp.addEventListener('click', function() {
    if (active) { end(); } else { start(); }
  });

  // 첫 방문 자동 시작은 PC 로드 완료 후 트리거 (startAfterLoad 참조)
  function startAfterLoad() {
    try {
      if (localStorage.getItem('dx3cal-tour-done')) return;
    } catch(e) { return; }
    if (active) return;
    start();
  }

  return {
    start: start, end: end, refresh: refresh, startAfterLoad: startAfterLoad,
    _pendingStart: function() { var p = _tourPendingStart; _tourPendingStart = false; return p; }
  };
})();

google.script.run
  .withSuccessHandler(function(payload) {
    var ns = (payload && payload.sheetNames) ? payload.sheetNames : [];
    CUR_SPREADSHEET_ID = String((payload && payload.spreadsheetId) || CUR_SPREADSHEET_ID || '');
    PC_SHEETS = ns.slice();
    var s = document.getElementById('pc-sel');
    ns.forEach(function(n) {
      var o = document.createElement('option');
      o.value = n; o.textContent = n; s.appendChild(o);
    });
    if (readMasterModePref()) setMasterMode(true, { initial: true });
    else updateMasterModeUi();
    // PC 목록 로드 완료 후 첫 방문 투어 시작
    if (GuideTour) GuideTour.startAfterLoad();
  })
  .getPcSheetNames();

function calcDP(er) {
  er = +er || 0;
  if (er >= 300) return 7; if (er >= 260) return 6; if (er >= 190) return 5;
  if (er >= 130) return 4; if (er >= 100) return 3; if (er >= 80)  return 2;
  if (er >= 60)  return 1; return 0;
}
function calcEL(er) {
  er = +er || 0;
  if (er >= 220) return 3; if (er >= 160) return 2; if (er >= 100) return 1; return 0;
}
function erStage(er) {
  er = +er || 0;
  if (er >= 160) return { label:'160%+', color:'#ff4d4d' };
  if (er >= 100) return { label:'100%+', color:'#ff8a66' };
  return { label:'99%\u2193', color:'#d0d0d0' };
}

function buildFeatOpts(d, firstLabel) {
  var opts = '<option value="NONE">' + firstLabel + '</option>';
  STAT_LABELS.forEach(function(sl, si) {
    opts += '<option value="STAT:' + si + '">\u300c' + sl + '\u300d</option>';
  });
  STAT_KEYS.forEach(function(k, si) {
    opts += '<optgroup label="\u2500 ' + STAT_LABELS[si] + ' \u2500">';
    (d.skills[k] || []).forEach(function(sk, ski) {
      if (sk.name) {
        opts += '<option value="SKILL:' + si + ':' + ski + ':' + sk.value + '">'
              + '\u3008' + esc(sk.name) + '\u3009 (' + sk.value + ')</option>';
      }
    });
    opts += '</optgroup>';
  });
  return opts;
}
function parseFeat(val) {
  if (!val || val === 'NONE') return { statIdx: -1, skillVal: 0 };
  var p = val.split(':');
  if (p[0] === 'STAT')  return { statIdx: +p[1], skillVal: 0 };
  if (p[0] === 'SKILL') return { statIdx: +p[1], skillVal: +p[3] || 0 };
  return { statIdx: -1, skillVal: 0 };
}
function getFeatLabel(raw) {
  if (!raw || raw === 'NONE') return '\u2015';
  var p = raw.split(':');
  if (p[0] === 'STAT') return '\u300c' + STAT_LABELS[+p[1]] + '\u300d';
  if (p[0] === 'SKILL' && G) {
    var sk = G.skills[STAT_KEYS[+p[1]]] && G.skills[STAT_KEYS[+p[1]]][+p[2]];
    return sk ? '\u3008' + sk.name + '\u3009' : raw;
  }
  return raw;
}

function getStateKey(sheetName) {
  var sn = String(sheetName || '').trim();
  var scope = CUR_SPREADSHEET_ID ? (String(CUR_SPREADSHEET_ID).trim() + ':') : '';
  return CACHE_PREFIX + scope + sn;
}

function getLegacyStateKey(sheetName) {
  return CACHE_PREFIX + String(sheetName || '').trim();
}

function getMasterModeStorageKey() {
  if (!CUR_SPREADSHEET_ID) return MASTER_MODE_KEY;
  return MASTER_MODE_KEY + ':' + String(CUR_SPREADSHEET_ID).trim();
}

function safeParseJson(raw) {
  try { return JSON.parse(raw); } catch (_) { return null; }
}

function readMasterModePref() {
  if (!window.localStorage) return false;
  var scopedKey = getMasterModeStorageKey();
  var raw = localStorage.getItem(scopedKey);
  if (raw == null && scopedKey !== MASTER_MODE_KEY) {
    raw = localStorage.getItem(MASTER_MODE_KEY);
    if (raw != null) {
      try { localStorage.setItem(scopedKey, raw); } catch (_) {}
    }
  }
  return raw === '1';
}

function writeMasterModePref(on) {
  if (!window.localStorage) return;
  try { localStorage.setItem(getMasterModeStorageKey(), on ? '1' : '0'); } catch (_) {}
}

function renderMasterTabs(activeSheet) {
  var host = document.getElementById('pc-master-tabs');
  if (!host) return;
  if (!MASTER_MODE) {
    host.innerHTML = '';
    return;
  }
  var names = (PC_SHEETS && PC_SHEETS.length) ? PC_SHEETS.slice() : Object.keys(MASTER_DATA || {});
  host.innerHTML = names.map(function(sn) {
    var entry = MASTER_DATA[sn];
    var hasErr = entry && entry.error;
    var noData = !entry;
    var cls = 'pc-tab' + (sn === activeSheet ? ' on' : '') + (hasErr ? ' err' : '') + (noData ? ' no-data' : '');
    var title = hasErr ? (sn + ' — ' + entry.error) : (noData ? (sn + ' (데이터 없음)') : sn);
    var disabled = (hasErr || noData) ? ' disabled' : '';
    var label = sn.length > 5 ? sn.slice(0, 5) + '\u2026' : sn;
    return '<button type="button" class="' + cls + '"' + disabled + ' data-sheet="' + esc(sn) + '" title="' + esc(title) + '">' + esc(label) + '</button>';
  }).join('');
}

function updateMasterModeUi() {
  var sel = document.getElementById('pc-sel');
  var tabs = document.getElementById('pc-master-tabs');
  var btn = document.getElementById('btn-master-mode');
  if (btn) btn.classList.toggle('on', MASTER_MODE);
  if (sel) sel.style.display = MASTER_MODE ? 'none' : '';
  if (tabs) tabs.style.display = MASTER_MODE ? 'flex' : 'none';
  updateMasterSaveAllButton();
  if (MASTER_MODE) renderMasterTabs(CUR_SHEET || '');
}

function updateMasterSaveAllButton() {
  var row = document.getElementById('pc-master-actions');
  var loadBtn = document.getElementById('btn-master-load-all');
  var saveBtn = document.getElementById('btn-master-save-all');
  var busy = IS_LOADING_CHAR || MASTER_LOAD_ALL_INFLIGHT || MASTER_SAVE_ALL_INFLIGHT;
  if (row) row.style.display = MASTER_MODE ? 'flex' : 'none';
  if (loadBtn) {
    loadBtn.disabled = !MASTER_MODE || !PC_SHEETS.length || busy;
    loadBtn.classList.toggle('busy', MASTER_LOAD_ALL_INFLIGHT);
  }
  if (saveBtn) {
    saveBtn.disabled = !MASTER_MODE || !PC_SHEETS.length || busy;
    saveBtn.classList.toggle('busy', MASTER_SAVE_ALL_INFLIGHT);
  }
}

function resetCharacterUiState() {
  cancelPendingStateSave();
  SEL = [];
  EF_CUSTOM = {};
  EF_COND = {};
  COCO_STATE = defaultCocoState();
  EXTRA_ADJ = defaultExtraAdj();
  resetRuntimeCaches();
}

function showSheetError(sheetName, msg) {
  document.getElementById('left-content').style.display = 'none';
  document.getElementById('right-content').style.display = 'none';
  var el = document.getElementById('right-empty');
  el.style.display = '';
  el.innerHTML = '<div style="color:#e07070;margin-bottom:6px;font-weight:bold">시트 오류: ' + esc(sheetName) + '</div>'
    + '<div style="color:#aaa;word-break:break-all">' + esc(msg || '알 수 없는 오류') + '</div>';
}

function applyCharacterData(sheetName, d) {
  if (!d || d.error) {
    showSheetError(sheetName, d && d.error ? d.error : '데이터를 불러올 수 없습니다.');
    return false;
  }
  CUR_SPREADSHEET_ID = String((d && d._spreadsheetId) || CUR_SPREADSHEET_ID || '');
  document.getElementById('right-empty').innerHTML = 'PC를 선택하고 불러오기를 눌러주세요';
  CUR_SHEET = sheetName;
  CUR_DATA_VERSION = String(d._version || CUR_DATA_VERSION || '');
  G = d;
  rebuildEffectIndex();
  initEqSelection(d);
  document.getElementById('left-content').innerHTML = buildLeft(d);
  document.getElementById('left-content').style.display = 'block';
  document.getElementById('right-content').style.display = 'block';
  document.getElementById('right-empty').style.display = 'none';
  attachEvents();
  restoreState(sheetName);
  onErInput();
  var sel = document.getElementById('pc-sel');
  if (sel) sel.value = sheetName;
  renderMasterTabs(sheetName);
  // 투어가 PC 로드를 기다리고 있었으면 재개
  if (GuideTour && GuideTour._pendingStart && GuideTour._pendingStart()) {
    setTimeout(function() { GuideTour.start(); }, 200);
  }
  return true;
}

function activateMasterSheet(sheetName) {
  var sn = String(sheetName || '').trim();
  if (!sn || !MASTER_MODE) return;
  if (CUR_SHEET && G && CUR_SHEET !== sn) saveCurrentState();
  var data = MASTER_DATA[sn];
  if (!data) {
    loadChar(sn);
    return;
  }
  resetCharacterUiState();
  applyCharacterData(sn, data);
}

function loadAllCharsMaster(forceRefresh) {
  if (!PC_SHEETS.length) return;
  var loadBtn = document.getElementById('btn-master-mode');
  setLoadingState(true);
  updateMasterSaveAllButton();
  if (loadBtn) loadBtn.disabled = true;
  google.script.run
    .withSuccessHandler(function(payload) {
      CUR_SPREADSHEET_ID = String((payload && payload.spreadsheetId) || CUR_SPREADSHEET_ID || '');
      setLoadingState(false);
      updateMasterSaveAllButton();
      if (loadBtn) loadBtn.disabled = false;
      MASTER_DATA = {};
      var names = (payload && payload.sheetNames && payload.sheetNames.length) ? payload.sheetNames : PC_SHEETS;
      names.forEach(function(sn) {
        if (payload && payload.dataBySheet && payload.dataBySheet[sn]) {
          MASTER_DATA[sn] = payload.dataBySheet[sn]; // includes error sheets
        }
      });
      var active = (CUR_SHEET && MASTER_DATA[CUR_SHEET] && !MASTER_DATA[CUR_SHEET].error) ? CUR_SHEET : '';
      if (!active) {
        for (var i = 0; i < names.length; i++) {
          if (MASTER_DATA[names[i]] && !MASTER_DATA[names[i]].error) { active = names[i]; break; }
        }
      }
      renderMasterTabs(active);
      if (active) activateMasterSheet(active);
      else {
        var allErr = names.every(function(sn) { return !MASTER_DATA[sn] || !!MASTER_DATA[sn].error; });
        if (allErr) showSheetError('', '불러올 수 있는 PC 데이터가 없습니다.');
      }
    })
    .withFailureHandler(function(e) {
      setLoadingState(false);
      updateMasterSaveAllButton();
      if (loadBtn) loadBtn.disabled = false;
      alert('오류: ' + (e.message || e));
    })
    .getAllCharacterData(!!forceRefresh);
}

function setMasterMode(on, opts) {
  var next = !!on;
  if (MASTER_MODE === next && !(opts && opts.force)) return;
  if (next && !PC_SHEETS.length) return;
  if (CUR_SHEET && G) saveCurrentState();
  MASTER_MODE = next;
  writeMasterModePref(next);
  updateMasterModeUi();
  if (next) {
    loadAllCharsMaster(false);
    return;
  }
  MASTER_DATA = {};
  var sel = document.getElementById('pc-sel');
  var sn = (sel && sel.value) || CUR_SHEET || (PC_SHEETS[0] || '');
  if (sel && sn) sel.value = sn;
  if (sn) loadChar(sn);
}

function getEffectStableKey(prefix, ef, idx) {
  if (ef && ef._key) return String(ef._key);
  return prefix + '-' + idx;
}

function ensureEffectBaseText(ef) {
  if (!ef) return '';
  if (typeof ef._baseEffect !== 'string') ef._baseEffect = String(ef.effect || '');
  return ef._baseEffect;
}

function getItemStableKey(type, item, idx) {
  if (item && item._key) return String(item._key);
  return String(type || 'item') + '-' + idx;
}

function getItemLegacyKey(type, item, idx) {
  if (item && item._legacyKey) return String(item._legacyKey);
  return String(type || 'item') + '-' + idx;
}

function rebuildEffectIndex() {
  EFFECT_INDEX = {};
  EFFECT_KEY_ALIAS = {};
  if (!G) return;
  (G.extraEffects || []).forEach(function(ef, idx) {
    var k = getEffectStableKey('ex', ef, idx);
    if (!ef._key) ef._key = k;
    ensureEffectBaseText(ef);
    [k, (ef && ef._legacyKey ? String(ef._legacyKey) : ''), 'ex-' + idx].forEach(function(alias) {
      if (!alias) return;
      EFFECT_INDEX[String(alias)] = ef;
      EFFECT_KEY_ALIAS[String(alias)] = k;
    });
  });
  (G.effects || []).forEach(function(ef, idx) {
    var k = getEffectStableKey('ef', ef, idx);
    if (!ef._key) ef._key = k;
    ensureEffectBaseText(ef);
    [k, (ef && ef._legacyKey ? String(ef._legacyKey) : ''), 'ef-' + idx].forEach(function(alias) {
      if (!alias) return;
      EFFECT_INDEX[String(alias)] = ef;
      EFFECT_KEY_ALIAS[String(alias)] = k;
    });
  });
}

function resolveEffectKeyFromState(rawKey) {
  var key = String(rawKey || '');
  if (!key) return key;
  if (EFFECT_KEY_ALIAS[key]) return EFFECT_KEY_ALIAS[key];
  var m = /^(ex|ef)-(\d+)$/.exec(key);
  if (!m || !G) return key;
  var list = (m[1] === 'ex') ? (G.extraEffects || []) : (G.effects || []);
  var idx = +m[2];
  if (isNaN(idx) || idx < 0 || !list[idx]) return key;
  var resolved = getEffectStableKey(m[1], list[idx], idx);
  return EFFECT_KEY_ALIAS[resolved] || resolved;
}

function migrateEffectKeyedObject(rawObj) {
  var out = {};
  var src = (rawObj && typeof rawObj === 'object') ? rawObj : {};
  Object.keys(src).forEach(function(k) {
    var resolved = resolveEffectKeyFromState(k);
    if (!resolved) return;
    out[resolved] = src[k];
  });
  return out;
}

function normalizeEqSelectionForCurrentData(rawEqSel) {
  var out = { weapon: {}, armor: {}, vehicle: {} };
  var src = (rawEqSel && typeof rawEqSel === 'object') ? rawEqSel : {};
  [
    ['weapon', (G && G.weapons) || []],
    ['armor', (G && G.armors) || []],
    ['vehicle', (G && G.vehicles) || []]
  ].forEach(function(pair) {
    var type = pair[0];
    var list = pair[1];
    var raw = src[type];
    list.forEach(function(item, idx) {
      var key = getItemStableKey(type, item, idx);
      var legacyKey = getItemLegacyKey(type, item, idx);
      var checked = true;
      if (Array.isArray(raw)) {
        checked = (raw[idx] !== false);
      } else if (raw && typeof raw === 'object') {
        if (Object.prototype.hasOwnProperty.call(raw, key)) checked = (raw[key] !== false);
        else if (Object.prototype.hasOwnProperty.call(raw, legacyKey)) checked = (raw[legacyKey] !== false);
        else if (Object.prototype.hasOwnProperty.call(raw, String(idx))) checked = (raw[String(idx)] !== false);
      }
      out[type][key] = checked;
    });
  });
  return out;
}

function defaultCocoState() {
  return { includeEffectText: false, useErosionDiceBonus: true, useErosionEffectLvBonus: true, useItemBonuses: true, memoText: '', memoPre: '', memoSuf: '', draftName: '', combos: [], separatorText: ' | ' };
}

function normalizeCocoComboEntry(raw, idx) {
  if (raw && typeof raw === 'object') {
    return {
      name: String(raw.name || ('콤보 ' + (idx + 1))),
      text: String(raw.text || ''),
      snapshot: (raw.snapshot && typeof raw.snapshot === 'object') ? raw.snapshot : null
    };
  }
  return {
    name: '콤보 ' + (idx + 1),
    text: String(raw || ''),
    snapshot: null
  };
}
function defaultExtraAdj() {
  return {
    sectionHidden: false,
    groups: [{
      name: '추가 수정치 1',
      enabled: true,
      hidden: false,
      rows: [{ field: 'achievement', value: '' }]
    }]
  };
}
function ensureExtraAdj() {
  if (!EXTRA_ADJ || typeof EXTRA_ADJ !== 'object') EXTRA_ADJ = defaultExtraAdj();
  if (typeof EXTRA_ADJ.sectionHidden !== 'boolean') EXTRA_ADJ.sectionHidden = false;
  if (!Array.isArray(EXTRA_ADJ.groups)) {
    var legacyRows = [];
    if (Array.isArray(EXTRA_ADJ.rows)) legacyRows = EXTRA_ADJ.rows;
    else if (typeof EXTRA_ADJ.field === 'string' || EXTRA_ADJ.value != null) legacyRows = [{ field: normalizeBonusFieldKey(EXTRA_ADJ.field || 'achievement'), value: String(EXTRA_ADJ.value == null ? '' : EXTRA_ADJ.value) }];
    EXTRA_ADJ.groups = [{
      name: '추가 수정치 1',
      enabled: true,
      hidden: false,
      rows: legacyRows
    }];
  }
  EXTRA_ADJ.groups = EXTRA_ADJ.groups.map(function(g, idx) {
    var name = (g && typeof g.name === 'string' && g.name.trim()) ? g.name.trim() : ('추가 수정치 ' + (idx + 1));
    var enabled = (g && typeof g.enabled === 'boolean') ? g.enabled : true;
    var hidden = (g && typeof g.hidden === 'boolean') ? g.hidden : false;
    var rows = (g && Array.isArray(g.rows)) ? g.rows : [];
    rows = rows.map(function(r) {
      var normalized = normalizeManualRuleStorage(
        (r && typeof r.field === 'string' && r.field) ? r.field : 'achievement',
        (r && r.value != null) ? r.value : ''
      );
      return { field: normalized.field, value: normalized.expr };
    });
    if (!rows.length) rows = [{ field: 'achievement', value: '' }];
    return { name: name, enabled: enabled, hidden: hidden, rows: rows };
  });
  if (!EXTRA_ADJ.groups.length) EXTRA_ADJ.groups = defaultExtraAdj().groups;
  return EXTRA_ADJ;
}

function toggleExtraAdjGroupHidden(groupIdx) {
  var st = ensureExtraAdj();
  var group = st.groups[groupIdx];
  if (!group) return;
  group.hidden = !group.hidden;
  renderExtraAdjEditor();
  saveCurrentStateDebounced();
}

function buildCurrentStateObject(opts) {
  var config = opts || {};
  var includeEffectTexts = (config.includeEffectTexts !== false);
  return {
    er: +((document.getElementById('inp-er') || {}).value || 0),
    featRaw: (document.getElementById('sel-feat') || {}).value || 'NONE',
    modRaw: (document.getElementById('sel-statmod') || {}).value || 'NONE',
    rangeVal: (document.getElementById('sel-range') || {}).value || RANGE_OPTIONS[0],
    targetVal: (document.getElementById('sel-target') || {}).value || TARGET_OPTIONS[0],
    selKeys: getSelectedEffectKeys(),
    eqsel: EQSEL,
    customEquip: CUSTOM_EQUIP,
    efCustom: EF_CUSTOM,
    efCond: EF_COND,
    effectTexts: includeEffectTexts ? buildEffectTextState() : {},
    extraAdj: EXTRA_ADJ,
    cocoState: COCO_STATE
  };
}

function readSheetState(sheetName) {
  if (!sheetName || !window.localStorage) return null;
  var scopedKey = getStateKey(sheetName);
  var raw = localStorage.getItem(scopedKey);
  if (raw == null) {
    var legacyKey = getLegacyStateKey(sheetName);
    if (legacyKey !== scopedKey) {
      raw = localStorage.getItem(legacyKey);
      if (raw != null) {
        try { localStorage.setItem(scopedKey, raw); } catch (_) {}
      }
    }
  }
  return safeParseJson(raw);
}

function writeSheetState(sheetName, state) {
  if (!sheetName || !window.localStorage) return;
  try { localStorage.setItem(getStateKey(sheetName), JSON.stringify(state)); } catch (_) {}
}

function clearSheetState(sheetName) {
  if (!sheetName || !window.localStorage) return;
  try { localStorage.removeItem(getStateKey(sheetName)); } catch (_) {}
  try { localStorage.removeItem(getLegacyStateKey(sheetName)); } catch (_) {}
}

function resetRuntimeCaches() {
  resetEffectDerivedCaches();
  _TOKEN_VALUE_MAP_CACHE = null;
  _TOKEN_VALUE_MAP_CACHE_KEY = '';
  CALC_VIEW_STATE = null;
}

function resetEffectDerivedCaches() {
  _EFFECT_PARSE_CACHE = {};
  _EFFECT_PARSE_CACHE_SIZE = 0;
  _SAVED_COCO_TEXT_CACHE = {};
  _SAVED_COCO_TEXT_CACHE_SIZE = 0;
}

function clearEffectParseCache() {
  resetEffectDerivedCaches();
}

function clearCurrentCache() {
  var sn = (document.getElementById('pc-sel') || {}).value || CUR_SHEET || '';
  if (!sn) { alert('먼저 PC를 선택해 주세요.'); return; }
  if (IS_LOADING_CHAR || REFRESH_INFLIGHT) return;
  cancelPendingStateSave();
  clearSheetState(sn);
  if (CUR_SHEET === sn) CUR_DATA_VERSION = '';
  if (CUR_SHEET === sn) {
    SEL = [];
    EQSEL = { weapon: {}, armor: {}, vehicle: {} };
    EF_CUSTOM = {};
    EF_COND = {};
    EXTRA_ADJ = defaultExtraAdj();
    COCO_STATE = defaultCocoState();
    resetRuntimeCaches();
  }
  var btn = document.getElementById('btn-clear-cache');
  function finishCacheClear() {
    REFRESH_INFLIGHT = false;
    if (btn) {
      btn.disabled = false;
      btn.classList.remove('refreshing');
    }
    loadChar(sn, { forceRefresh: true });
  }
  REFRESH_INFLIGHT = true;
  if (btn) {
    btn.disabled = true;
    btn.classList.add('refreshing');
  }
  try {
    google.script.run
      .withSuccessHandler(function() {
        finishCacheClear();
      })
      .withFailureHandler(function() {
        finishCacheClear();
      })
      .clearCharacterDataCache(sn);
  } catch (_) {
    finishCacheClear();
  }
}

function getSelectedEffectKeys() {
  return (SEL || []).map(function(s) { return s.key; });
}

function applySelectedEffects(selKeys) {
  SEL = [];
  var seen = {};
  document.querySelectorAll('.ef-chk').forEach(function(chk) { chk.checked = false; });
  document.querySelectorAll('.ef-item.on').forEach(function(card) { card.classList.remove('on'); });
  (selKeys || []).forEach(function(rawKey) {
    var key = resolveEffectKeyFromState(rawKey);
    if (!key || seen[key]) return;
    seen[key] = true;
    var chk = document.getElementById('chk-' + key);
    var card = document.getElementById('ei-' + key);
    var ef = getEffectByKey(key);
    if (!chk || !ef) return;
    chk.checked = true;
    if (card) card.classList.add('on');
    SEL.push({ key: key, ef: ef });
  });
}

function saveCurrentState(opts) {
  var sheetName = CUR_SHEET || (document.getElementById('pc-sel') || {}).value || '';
  if (!sheetName || !G) return;
  var state = snapshotCurrentState(opts);
  cancelPendingStateSave();
  writeSheetState(sheetName, state);
}

function applyStateData(state) {
  if (!state || typeof state !== 'object') return;
  EF_CUSTOM = migrateEffectKeyedObject(state.efCustom || {});
  EF_COND = migrateEffectKeyedObject(state.efCond || {});
  EXTRA_ADJ = state.extraAdj || defaultExtraAdj();
  ensureExtraAdj();
  COCO_STATE = state.cocoState || defaultCocoState();
  COCO_STATE.includeEffectText = !!COCO_STATE.includeEffectText;
  if (typeof COCO_STATE.useErosionDiceBonus !== 'boolean') {
    if (typeof COCO_STATE.useErosionBonus === 'boolean') COCO_STATE.useErosionDiceBonus = COCO_STATE.useErosionBonus;
    else COCO_STATE.useErosionDiceBonus = true;
  }
  if (typeof COCO_STATE.useErosionEffectLvBonus !== 'boolean') {
    if (typeof COCO_STATE.useEffectBonus === 'boolean') COCO_STATE.useErosionEffectLvBonus = COCO_STATE.useEffectBonus;
    else if (typeof COCO_STATE.useItemStatsBonus === 'boolean') COCO_STATE.useErosionEffectLvBonus = COCO_STATE.useItemStatsBonus;
    else COCO_STATE.useErosionEffectLvBonus = true;
  }
  if (typeof COCO_STATE.useItemBonuses !== 'boolean') COCO_STATE.useItemBonuses = true;
  if (Object.prototype.hasOwnProperty.call(COCO_STATE, 'useErosionBonus')) delete COCO_STATE.useErosionBonus;
  if (Object.prototype.hasOwnProperty.call(COCO_STATE, 'useEffectBonus')) delete COCO_STATE.useEffectBonus;
  if (Object.prototype.hasOwnProperty.call(COCO_STATE, 'useItemStatsBonus')) delete COCO_STATE.useItemStatsBonus;
  if (typeof COCO_STATE.memoText !== 'string') COCO_STATE.memoText = '';
  if (typeof COCO_STATE.memoPre !== 'string') COCO_STATE.memoPre = '';
  if (typeof COCO_STATE.memoSuf !== 'string') COCO_STATE.memoSuf = '';
  if (!Array.isArray(COCO_STATE.combos)) COCO_STATE.combos = [];
  COCO_STATE.combos = COCO_STATE.combos.map(function(c, i) { return normalizeCocoComboEntry(c, i); });
  if (typeof COCO_STATE.draftName !== 'string') COCO_STATE.draftName = '';
  if (typeof COCO_STATE.separatorText !== 'string') COCO_STATE.separatorText = ' | ';
  EQSEL = normalizeEqSelectionForCurrentData(state.eqsel);
  CUSTOM_EQUIP = state.customEquip && typeof state.customEquip === 'object'
    ? { weapon: state.customEquip.weapon || [], armor: state.customEquip.armor || [], vehicle: state.customEquip.vehicle || [] }
    : { weapon: [], armor: [], vehicle: [] };
  resetAllEffectTextsToBase();
  applyEffectTextState(state.effectTexts || {});
  [G.extraEffects || [], G.effects || []].forEach(function(list, listIdx) {
    var prefix = listIdx === 0 ? 'ex' : 'ef';
    list.forEach(function(ef, idx) {
      syncEffectConditionState(getEffectStableKey(prefix, ef, idx), ef);
    });
  });
  resetEffectDerivedCaches();

  if (typeof state.er === 'number' && document.getElementById('inp-er')) {
    document.getElementById('inp-er').value = state.er;
  }
  var featEl = document.getElementById('sel-feat');
  if (featEl && state.featRaw) featEl.value = state.featRaw;
  var modEl = document.getElementById('sel-statmod');
  if (modEl && state.modRaw) modEl.value = state.modRaw;
  if (state.rangeVal) setCustomComboValue('sel-range', state.rangeVal);
  if (state.targetVal) setCustomComboValue('sel-target', state.targetVal);
  renderExtraAdjEditor();
  syncCocoOptionCheckboxes(COCO_STATE);

  document.querySelectorAll('.eq-chk').forEach(function(chk) {
    var type = chk.getAttribute('data-type');
    var key = chk.getAttribute('data-key') || '';
    chk.checked = isEquipChecked(type, key);
  });
  document.querySelectorAll('.ef-manual[id^="ef-manual-"]').forEach(function(node) {
    var key = node.id.replace('ef-manual-', '');
    node.innerHTML = buildManualEditorHtml(key);
  });
  document.querySelectorAll('[id^="ef-tags-"]').forEach(function(node) {
    var key = node.id.replace('ef-tags-', '');
    renderEffectTags(key);
  });
  document.querySelectorAll('[id^="ef-text-"]').forEach(function(node) {
    var key = node.id.replace('ef-text-', '');
    syncEffectTextEditorUi(key);
  });
  var nextSelKeys = (state.selKeys || []).map(resolveEffectKeyFromState).filter(function(k) { return !!k && !!EFFECT_KEY_ALIAS[k]; });
  applySelectedEffects(nextSelKeys);
}

function restoreState(sheetName) {
  var state = readSheetState(sheetName);
  if (!state) return;
  applyStateData(state);
}

function buildImportWarningLines(obj) {
  var lines = [];
  var payload = (obj && typeof obj === 'object') ? obj : {};
  var importedSheetName = String(payload.sheetName || '');
  var importedCharacterName = String(payload.characterName || '');
  var currentSheetName = String(CUR_SHEET || '');
  var currentCharacterName = String((G && G.name) || '');

  if (payload.version && Number(payload.version) > 2) {
    lines.push('더 최신 버전에서 저장한 파일일 수 있습니다.');
  }
  if (importedCharacterName && currentCharacterName && importedCharacterName !== currentCharacterName) {
    lines.push('캐릭터명이 다릅니다: ' + importedCharacterName + ' -> ' + currentCharacterName);
  }
  if (importedSheetName && currentSheetName && importedSheetName !== currentSheetName) {
    lines.push('시트명이 다릅니다: ' + importedSheetName + ' -> ' + currentSheetName);
  }
  return lines;
}

function buildCurrentStatePayload() {
  return {
    version: 2,
    sheetName: CUR_SHEET || '',
    characterName: G.name || '',
    savedAt: new Date().toISOString(),
    state: buildCurrentStateObject()
  };
}

function buildStatePayloadForSheet(sheetName, state, characterName) {
  return {
    version: 2,
    sheetName: String(sheetName || ''),
    characterName: String(characterName || ''),
    savedAt: new Date().toISOString(),
    state: cloneJsonSafe(state, state)
  };
}

function buildDefaultStateObjectFromData(data) {
  return {
    er: +((data && data.erosion) || 0),
    featRaw: 'NONE',
    modRaw: 'NONE',
    rangeVal: RANGE_OPTIONS[0] || '무기',
    targetVal: TARGET_OPTIONS[0] || '자신',
    selKeys: [],
    eqsel: buildEqSelectionFromData(data),
    customEquip: { weapon: [], armor: [], vehicle: [] },
    efCustom: {},
    efCond: {},
    effectTexts: {},
    extraAdj: defaultExtraAdj(),
    cocoState: defaultCocoState()
  };
}

function collectMasterSheetStatePayloads(savedStateResult) {
  var payloadBySheet = {};
  var skippedSheets = [];
  var preservedSheets = [];
  var defaultedSheets = [];
  var errorSheets = [];
  var dataBySheet = (savedStateResult && savedStateResult.dataBySheet && typeof savedStateResult.dataBySheet === 'object')
    ? savedStateResult.dataBySheet
    : {};
  flushPendingStateSave();
  if (MASTER_MODE && CUR_SHEET && G) saveCurrentState();
  (PC_SHEETS || []).forEach(function(sheetName) {
    var sn = String(sheetName || '').trim();
    if (!sn) return;
    if (sn === CUR_SHEET && G) {
      payloadBySheet[sn] = buildCurrentStatePayload();
      return;
    }
    var localState = readSheetState(sn);
    if (localState && typeof localState === 'object') {
      payloadBySheet[sn] = buildStatePayloadForSheet(
        sn,
        localState,
        (MASTER_DATA[sn] && MASTER_DATA[sn].name)
          || ((dataBySheet[sn] && dataBySheet[sn].payload && dataBySheet[sn].payload.characterName) || '')
      );
      return;
    }
    var savedEntry = dataBySheet[sn];
    if (savedEntry && savedEntry.ok === false) {
      errorSheets.push(sn);
      return;
    }
    var savedState = extractStateFromImportObject(savedEntry && savedEntry.payload);
    if (savedState) {
      payloadBySheet[sn] = buildStatePayloadForSheet(
        sn,
        savedState,
        (savedEntry && savedEntry.payload && savedEntry.payload.characterName)
          || ((MASTER_DATA[sn] && MASTER_DATA[sn].name) || '')
      );
      preservedSheets.push(sn);
      return;
    }
    var data = MASTER_DATA[sn];
    if (data && !data.error) {
      payloadBySheet[sn] = buildStatePayloadForSheet(sn, buildDefaultStateObjectFromData(data), data.name || '');
      defaultedSheets.push(sn);
      return;
    }
    skippedSheets.push(sn);
  });
  return {
    payloadBySheet: payloadBySheet,
    skippedSheets: skippedSheets,
    preservedSheets: preservedSheets,
    defaultedSheets: defaultedSheets,
    errorSheets: errorSheets
  };
}

function saveAllMasterStatesToSheetSlots() {
  if (!MASTER_MODE) { showToast('마스터 모드에서만 사용할 수 있습니다.', 'info'); return; }
  if (!PC_SHEETS.length) { showToast('저장할 PC 시트가 없습니다.', 'info'); return; }
  if (MASTER_SAVE_ALL_INFLIGHT) return;
  if (!window.confirm('현재 마스터 모드 상태를 각 시트 저장 슬롯에 저장합니다.\n로컬 상태가 없는 PC는 기존 시트 저장값을 유지합니다.\n계속하시겠습니까?')) return;

  MASTER_SAVE_ALL_INFLIGHT = true;
  updateMasterSaveAllButton();
  google.script.run
    .withSuccessHandler(function(savedStateResult) {
      var collected = collectMasterSheetStatePayloads(savedStateResult);
      var sheetNames = Object.keys(collected.payloadBySheet || {});
      if (!sheetNames.length) {
        MASTER_SAVE_ALL_INFLIGHT = false;
        updateMasterSaveAllButton();
        if (collected.errorSheets.length) {
          showToast('저장 슬롯 확인 중 오류: ' + collected.errorSheets.join(', '), 'error', 3200);
        } else {
          showToast('저장할 상태를 찾지 못했습니다.', 'info');
        }
        return;
      }

      google.script.run
        .withSuccessHandler(function(result) {
          MASTER_SAVE_ALL_INFLIGHT = false;
          updateMasterSaveAllButton();
          var lines = [
            '마스터 전체 저장 완료',
            '- 저장: ' + String((result && result.savedCount) || 0) + '개'
          ];
          if (collected.preservedSheets.length) lines.push('- 기존 저장 유지: ' + collected.preservedSheets.length + '개');
          if (collected.defaultedSheets.length) lines.push('- 신규 기본값 생성: ' + collected.defaultedSheets.length + '개');
          if (collected.skippedSheets.length) lines.push('- 건너뜀: ' + collected.skippedSheets.join(', '));
          if (collected.errorSheets.length) lines.push('- 저장 전 확인 오류: ' + collected.errorSheets.join(', '));
          if (result && result.errorCount) {
            var errorNames = Object.keys(result.errors || {});
            lines.push('- 저장 오류: ' + errorNames.join(', '));
          }
          showToast(
            lines.join('\n'),
            ((result && result.errorCount) || collected.errorSheets.length) ? 'error' : 'success',
            3600
          );
        })
        .withFailureHandler(function(e) {
          MASTER_SAVE_ALL_INFLIGHT = false;
          updateMasterSaveAllButton();
          showToast('마스터 전체 저장 중 오류: ' + (e && e.message ? e.message : e), 'error', 3200);
        })
        .setAllCharacterUiStates(collected.payloadBySheet);
    })
    .withFailureHandler(function(e) {
      MASTER_SAVE_ALL_INFLIGHT = false;
      updateMasterSaveAllButton();
      showToast('기존 저장 상태 확인 중 오류: ' + (e && e.message ? e.message : e), 'error', 3200);
    })
    .getAllCharacterUiStates(PC_SHEETS);
}

function saveStateToSheetSlot() {
  if (!G) { showToast('먼저 PC를 불러와 주세요.', 'info'); return; }
  var payload = buildCurrentStatePayload();
  var btn = document.getElementById('btn-export-state');
  if (btn) btn.disabled = true;
  google.script.run
    .withSuccessHandler(function(info) {
      if (btn) btn.disabled = false;
      saveCurrentState();
      showToast('현재 상태를 시트 ' + String((info && info.sheetName) || payload.sheetName) + '의 ' + String((info && info.cellA1) || 'AG240') + ' 셀에 저장했습니다.', 'success');
    })
    .withFailureHandler(function(e) {
      if (btn) btn.disabled = false;
      showToast('시트 저장 중 오류: ' + (e && e.message ? e.message : e), 'error', 3200);
    })
    .setCharacterUiState(payload.sheetName, payload);
}

function importStateFromObject(obj) {
  if (!obj || typeof obj !== 'object') return 'invalid';
  var state = obj.state && typeof obj.state === 'object' ? obj.state : obj;
  if (!state || typeof state !== 'object') return 'invalid';
  var warnings = buildImportWarningLines(obj);
  if (warnings.length) {
    var ok = window.confirm(
      '불러오기 경고:\n- ' + warnings.join('\n- ') + '\n\n계속 불러오시겠습니까?'
    );
    if (!ok) return 'cancel';
  }
  applyStateData(state);
  saveCurrentState();
  onErInput();
  return 'ok';
}

function extractStateFromImportObject(obj) {
  if (!obj || typeof obj !== 'object') return null;
  var state = (obj.state && typeof obj.state === 'object') ? obj.state : obj;
  return (state && typeof state === 'object') ? state : null;
}

function applyImportedStateToSheet(sheetName, payload, opts) {
  var state = extractStateFromImportObject(payload);
  if (!state) return false;
  if (opts && opts.applyNow) {
    applyStateData(state);
    saveCurrentState();
    onErInput();
    return true;
  }
  writeSheetState(sheetName, state);
  return true;
}

function loadStateFromSheetSlot() {
  if (!G) { alert('먼저 PC를 불러와 주세요.'); return; }
  var sheetName = CUR_SHEET || (document.getElementById('pc-sel') || {}).value || '';
  if (!sheetName) { alert('먼저 PC를 선택해 주세요.'); return; }
  var btn = document.getElementById('btn-import-state');
  if (btn) btn.disabled = true;
  google.script.run
    .withSuccessHandler(function(result) {
      if (btn) btn.disabled = false;
      if (!result || result.ok === false) {
        alert((result && result.error) ? result.error : '시트 저장 상태를 읽는 중 오류가 발생했습니다.');
        return;
      }
      if (!result.exists || !result.payload) {
        alert('현재 시트의 ' + String(result.cellA1 || 'AG240') + ' 셀에 저장된 상태가 없습니다.');
        return;
      }
      var importResult = importStateFromObject(result.payload);
      if (importResult === 'invalid') {
        alert('시트 저장 데이터 형식이 올바르지 않습니다.');
      } else if (importResult === 'ok') {
        alert('시트 ' + String(result.sheetName || sheetName) + '의 ' + String(result.cellA1 || 'AG240') + ' 셀에서 상태를 불러왔습니다.');
      }
    })
    .withFailureHandler(function(e) {
      if (btn) btn.disabled = false;
      alert('시트 불러오기 중 오류: ' + (e && e.message ? e.message : e));
    })
    .getCharacterUiState(sheetName);
}

function loadAllMasterStatesFromSheetSlots() {
  if (!MASTER_MODE) { alert('마스터 모드에서만 사용할 수 있습니다.'); return; }
  if (!PC_SHEETS.length) { alert('불러올 PC 시트가 없습니다.'); return; }
  if (MASTER_LOAD_ALL_INFLIGHT) return;
  if (!window.confirm('현재 마스터 모드의 로컬 상태를 시트 저장 상태로 덮어씁니다.\n계속하시겠습니까?')) return;

  cancelPendingStateSave();
  MASTER_LOAD_ALL_INFLIGHT = true;
  updateMasterSaveAllButton();
  google.script.run
    .withSuccessHandler(function(result) {
      MASTER_LOAD_ALL_INFLIGHT = false;
      updateMasterSaveAllButton();
      var loadedSheets = [];
      var skippedSheets = [];
      var errorSheets = [];
      var activePayload = null;

      (PC_SHEETS || []).forEach(function(sheetName) {
        var entry = result && result.dataBySheet ? result.dataBySheet[sheetName] : null;
        if (!entry || entry.ok === false) {
          errorSheets.push(sheetName);
          return;
        }
        if (!entry.exists || !entry.payload) {
          skippedSheets.push(sheetName);
          return;
        }
        if (sheetName === CUR_SHEET && G) {
          activePayload = entry.payload;
          return;
        }
        if (applyImportedStateToSheet(sheetName, entry.payload)) loadedSheets.push(sheetName);
        else errorSheets.push(sheetName);
      });

      if (activePayload) {
        if (applyImportedStateToSheet(CUR_SHEET, activePayload, { applyNow: true })) loadedSheets.push(CUR_SHEET);
        else errorSheets.push(CUR_SHEET);
      }

      var lines = ['마스터 전체 불러오기 완료', '- 불러옴: ' + loadedSheets.length + '개'];
      if (skippedSheets.length) lines.push('- 저장 없음: ' + skippedSheets.join(', '));
      if (errorSheets.length) lines.push('- 오류: ' + errorSheets.join(', '));
      alert(lines.join('\n'));
    })
    .withFailureHandler(function(e) {
      MASTER_LOAD_ALL_INFLIGHT = false;
      updateMasterSaveAllButton();
      alert('마스터 전체 불러오기 중 오류: ' + (e && e.message ? e.message : e));
    })
    .getAllCharacterUiStates(PC_SHEETS);
}

function refreshCharacterData() {
  var sn = CUR_SHEET || (document.getElementById('pc-sel') || {}).value || '';
  if (!sn || !G || IS_LOADING_CHAR || REFRESH_INFLIGHT) return;
  if (hasEffectTextEdits()) {
    saveCurrentState({ includeEffectTexts: false });
    loadChar(sn, { forceRefresh: true });
    return;
  }
  var btn = document.getElementById('btn-clear-cache');
  REFRESH_INFLIGHT = true;
  if (btn) btn.classList.add('refreshing');
  google.script.run
    .withSuccessHandler(function(info) {
      REFRESH_INFLIGHT = false;
      if (btn) btn.classList.remove('refreshing');
      var nextVer = String((info || {}).version || '');
      if (!nextVer) return;
      if (!CUR_DATA_VERSION) { CUR_DATA_VERSION = nextVer; return; }
      if (nextVer === CUR_DATA_VERSION) return;
      CUR_DATA_VERSION = nextVer;
      saveCurrentState({ includeEffectTexts: false });
      loadChar(sn, { forceRefresh: true });
    })
    .withFailureHandler(function() {
      REFRESH_INFLIGHT = false;
      if (btn) btn.classList.remove('refreshing');
    })
    .getCharacterDataVersion(sn);
}

function loadChar(sheetName, opts) {
  var sel = document.getElementById('pc-sel');
  var sn = String(sheetName || (sel && sel.value) || '').trim();
  var forceRefresh = !!(opts && opts.forceRefresh);
  if (!sn) { alert('PC\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694'); return; }
  if (CUR_SHEET && G && CUR_SHEET !== sn) saveCurrentState();
  CUR_SHEET = sn;
  var loadSeq = ++LOAD_SEQ;
  setLoadingState(true);
  var loadBtn = document.getElementById('btn-load');
  if (loadBtn) loadBtn.disabled = true;
  resetCharacterUiState();
  google.script.run
    .withSuccessHandler(function(d) {
      if (loadSeq !== LOAD_SEQ) return;
      setLoadingState(false);
      if (loadBtn) loadBtn.disabled = false;
      if (!applyCharacterData(sn, d)) return;
      if (MASTER_MODE) MASTER_DATA[sn] = d;
      renderMasterTabs(sn);
    })
    .withFailureHandler(function(e) {
      if (loadSeq !== LOAD_SEQ) return;
      setLoadingState(false);
      if (loadBtn) loadBtn.disabled = false;
      alert('\uc624\ub958: ' + (e.message || e));
    })
    .getCharacterData(sn, forceRefresh);
}

function initEqSelection(d) {
  EQSEL = buildEqSelectionFromData(d);
}

function buildEqSelectionFromData(d) {
  var out = { weapon: {}, armor: {}, vehicle: {} };
  ((d && d.weapons) || []).forEach(function(w, i) { out.weapon[getItemStableKey('weapon', w, i)] = true; });
  ((d && d.armors) || []).forEach(function(a, i) { out.armor[getItemStableKey('armor', a, i)] = true; });
  ((d && d.vehicles) || []).forEach(function(v, i) { out.vehicle[getItemStableKey('vehicle', v, i)] = true; });
  return out;
}

function buildErosionIgnoreBoxHtml() {
  var st = ensureCocoState();
  return '<div id="er-ignore-box" class="er-ignore-box">'
       + '<div class="er-ignore-title">미반영 옵션</div>'
       + '<div class="er-ignore-list">'
       + '<label class="er-ignore-opt" title="다이스식과 코코포리아 복사용 텍스트에서 침식률 다이스 보너스를 제외합니다"><input type="checkbox" id="chk-erosion-dice" class="coco-opt-chk"' + (st.useErosionDiceBonus === false ? ' checked' : '') + '><span>침식 다이스 미반영</span></label>'
       + '<label class="er-ignore-opt" title="다이스식과 코코포리아 복사용 텍스트에서 침식률로 증가한 이펙트 레벨 보너스를 제외합니다"><input type="checkbox" id="chk-effect-bonus" class="coco-opt-chk"' + (st.useErosionEffectLvBonus === false ? ' checked' : '') + '><span>침식 이펙트 레벨 미반영</span></label>'
       + '<label class="er-ignore-opt" title="무기·방어구·비클과 직접 추가한 장비의 모든 보정값을 결과와 코코포리아 복사용 텍스트에서 제외합니다"><input type="checkbox" id="chk-item-bonus" class="coco-opt-chk"' + (st.useItemBonuses === false ? ' checked' : '') + '><span>모든 아이템 보정 미반영</span></label>'
       + '</div>'
       + '</div>';
}

function buildLeft(d) {
  var er   = d.erosion;
  var st   = erStage(er);
  var fopts = buildFeatOpts(d, '--- \uc5c6\uc74c');
  var mopts = buildFeatOpts(d, '--- \uae30\ub2a5 \uadf8\ub300\ub85c');

  var html = '';

  html += '<div class="c-card">';
  html += '<div class="c-head">'
       + '<div class="c-name-wrap">'
       + '<div class="c-name">' + esc(d.name) + '</div>'
       + '<div id="char-loading" class="char-loading' + (IS_LOADING_CHAR ? ' on' : '') + '" aria-label="로딩 중"><div class="spin"></div></div>'
       + '</div>'
       + '<div class="c-actions">'
       + '<button type="button" id="btn-export-state" class="icon-btn" title="시트 저장" aria-label="시트 저장">'
       + '<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>'
       + '</button>'
       + '<button type="button" id="btn-import-state" class="icon-btn" title="시트 불러오기" aria-label="시트 불러오기">'
       + '<svg viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>'
       + '</button>'
       + '</div></div>';

  html += '<div class="er-row">'
        + '<label>\uce68\uc2dd\ub960</label>'
        + '<input id="inp-er" type="number" value="' + er + '" min="0" max="300">'
        + '<span class="er-pct">%</span>'
        + '<span id="er-stage" class="er-stage" style="color:' + st.color + '">' + st.label + '</span>'
        + '<button type="button" id="btn-stat-all" class="stat-all-tgl er-stat-tgl" title="\ub2a5\ub825\uce58 \ud3bc\uce58\uae30/\uc811\uae30" aria-label="\ub2a5\ub825\uce58 \ud3bc\uce58\uae30/\uc811\uae30">'
        + '<svg viewBox="0 0 24 24"><polyline points="6 9 12 15 18 9"></polyline></svg>'
        + '</button>'
        + '</div>';
  html += '<div class="badge-row" id="er-badges">' + buildBadges(er, d) + '</div>';

  html += buildStatGrid(d);

  html += '<div class="param-grid" style="margin-top:6px">';
  html += pCell('sel-feat',    '\uae30\ub2a5',           fopts);
  html += pCell('sel-statmod', '\ub2a5\ub825\uce58 \ubcc0\ub3d9', mopts);
  html += pCustomCombo('sel-range', '\uc0ac\uc815', RANGE_OPTIONS, RANGE_OPTIONS[0]);
  html += pCustomCombo('sel-target', '\ub300\uc0c1', TARGET_OPTIONS, TARGET_OPTIONS[0]);
  html += '</div>';
  html += '</div>';

  html += '<div class="tabs">'
        + '<button class="tab on" id="tab-ef">\uc774\ud399\ud2b8</button>'
        + '<button class="tab" id="tab-wp">\ubb34\uae30/\ubc29\uc5b4\uad6c</button>'
        + '<button class="tab" id="tab-vh">\ube44\ud074</button>'
        + '</div>';

  html += '<div id="tp-ef" class="tp on">';
  html += '<div id="extra-adj-wrap">' + buildExtraAdjEditor() + '</div>';
  if (d.effects && d.effects.length) {
    html += '<div class="sub-lbl">\uc774\ud399\ud2b8</div>' + buildEfList(d.effects, 'ef');
  }
  if (d.extraEffects && d.extraEffects.length) {
    html += '<div class="sub-lbl" style="margin-top:4px">\ucd94\uac00 \uc774\ud399\ud2b8</div>' + buildEfList(d.extraEffects, 'ex');
  }
  html += '</div>';
  html += '<div id="tp-wp" class="tp">' + buildWeapTable(d.weapons) + buildArmorTable(d.armors) + '</div>';
  html += '<div id="tp-vh" class="tp">' + buildVehTable(d.vehicles) + '</div>';

  return html;
}

function buildBadges(er, d) {
  var dp = calcDP(er), el = calcEL(er);
  function valOrDash(v) {
    return (v === 0 || (v !== null && v !== undefined && String(v) !== '')) ? v : '\u2015';
  }
  return '<span class="bdg b-dp">DICE+ ' + dp + '</span>'
       + '<span class="bdg b-el">EFF.LV+ ' + el + '</span>'
       + '<span class="bdg b-ini">\ud589\ub3d9 ' + valOrDash(d && d.initiative) + '</span>'
       + '<span class="bdg b-move">전투 이동 ' + valOrDash(d && d.combatMove) + '</span>'
       + '<span class="bdg b-move">전력 이동 ' + valOrDash(d && d.fullMove) + '</span>';
}

function buildStatGrid(d) {
  var html = '<div id="stat-wrap" class="stat-wrap collapsed"><div class="stat-grid">';
  STAT_KEYS.forEach(function(k, i) {
    var val = d.statValues[k];
    html += '<div class="s-card">'
          + '<div class="s-head"><span>' + STAT_LABELS[i] + '</span><span class="sv">' + val + '</span></div>'
          + '<div class="s-body">';
    (d.skills[k] || []).forEach(function(sk) {
      if (sk.name) html += '<div class="sk-row"><span class="sk-n" title="' + esc(sk.name) + '">' + esc(sk.name) + '</span><span class="sk-v">' + sk.value + '</span></div>';
    });
    html += '</div></div>';
  });
  return html + '</div></div>';
}

function pCell(id, lbl, opts) {
  return '<div class="p-cell"><label>' + lbl + '</label><select id="' + id + '">' + opts + '</select></div>';
}

function pCustomCombo(id, lbl, options, defaultValue) {
  var initVal = defaultValue || options[0] || '';
  var list = '';
  options.forEach(function(opt) { list += '<option value="' + esc(opt) + '">'; });
  var dlId = id + '-list';
  return '<div class="p-cell"><label>' + lbl + '</label>'
       + '<input type="text" id="' + id + '" value="' + esc(initVal) + '" list="' + dlId + '">'
       + '<datalist id="' + dlId + '">' + list + '</datalist></div>';
}

function iconPlusSvg() { return ICON_SVG.plus; }
function iconMinusSvg() { return ICON_SVG.minus; }
function iconUndoSvg() { return ICON_SVG.undo; }
function iconResetSvg() { return ICON_SVG.reset; }
function iconEyeSvg() { return ICON_SVG.eye; }
function iconEyeOffSvg() { return ICON_SVG.eyeOff; }
function iconChevronDownSvg() { return ICON_SVG.chevronDown; }
function iconChevronRightSvg() { return ICON_SVG.chevronRight; }

function buildExtraAdjEditor() {
  var st = ensureExtraAdj();
  var sectionHidden = !!st.sectionHidden;
  var groupsHtml = (st.groups || []).map(function(group, groupIdx) {
    var g = group || {};
    var rowList = (g.rows || []);
    var rows = rowList.map(function(row, rowIdx) {
      var delBtnStyle = (rowList.length <= 1) ? ' style="visibility:hidden"' : '';
      return '<div class="extra-adj-row">'
           + '<select class="extra-adj-field" data-group="' + groupIdx + '" data-row="' + rowIdx + '">' + buildExtraAdjFieldGroupOptionsHtml(row.field || 'achievement') + '</select>'
           + '<input class="extra-adj-val" data-group="' + groupIdx + '" data-row="' + rowIdx + '" value="' + esc(getManualRuleDisplayExpr(row.field || 'achievement', row.value || '')) + '" placeholder="+2 또는 LV+1D">'
           + '<button type="button" class="extra-adj-del" title="행 삭제" aria-label="행 삭제" data-group="' + groupIdx + '" data-row="' + rowIdx + '"' + delBtnStyle + '>' + iconMinusSvg() + '</button>'
           + '</div>';
    }).join('');
    var groupHidden = !!g.hidden;
    var groupName = String(g.name || ('추가 수정치 ' + (groupIdx + 1)));
    return '<div class="extra-adj-group">'
         + '<div class="extra-adj-group-head">'
         + '<div class="extra-adj-name-wrap">'
         + '<label class="extra-adj-on"><input type="checkbox" class="extra-adj-chk" data-group="' + groupIdx + '" aria-label="적용"' + (g.enabled === false ? '' : ' checked') + '></label>'
         + '<input type="text" class="extra-adj-name" data-group="' + groupIdx + '" value="' + esc(groupName) + '" placeholder="그룹 이름">'
         + '</div>'
         + '<button type="button" class="extra-adj-head-btn extra-adj-group-del" title="그룹 삭제" aria-label="그룹 삭제" data-group="' + groupIdx + '"' + ((st.groups || []).length <= 1 ? ' style="visibility:hidden"' : '') + '>' + iconMinusSvg() + '</button>'
         + '<button type="button" class="extra-adj-row-add" title="행 추가" aria-label="행 추가" data-group="' + groupIdx + '">' + iconPlusSvg() + '</button>'
         + '<button type="button" class="extra-adj-head-btn extra-adj-hide' + (groupHidden ? '' : ' open') + '" title="' + (groupHidden ? '그룹 펼치기' : '그룹 접기') + '" aria-label="' + (groupHidden ? '그룹 펼치기' : '그룹 접기') + '" data-group="' + groupIdx + '">' + iconChevronDownSvg() + '</button>'
         + '</div>'
         + '<div class="extra-adj-group-body' + (groupHidden ? ' off' : '') + '">'
         + (rows || '<div class="extra-adj-empty">수정치가 없습니다.</div>')
         + '</div>'
         + '</div>';
  }).join('');
  if (!groupsHtml) groupsHtml = '<div class="extra-adj-empty">그룹이 없습니다.</div>';
  return '<div class="extra-adj">'
       + '<div class="extra-adj-top">'
       + '<div class="extra-adj-hd">추가 수정치 그룹</div>'
       + '<div class="extra-adj-top-actions">'
       + '<button type="button" id="btn-extra-adj-group-add" class="extra-adj-head-btn" title="그룹 추가" aria-label="그룹 추가">' + iconPlusSvg() + '</button>'
       + '<button type="button" id="btn-extra-adj-section-hide" class="extra-adj-head-btn extra-adj-section-toggle' + (sectionHidden ? '' : ' open') + '" title="' + (sectionHidden ? '추가 수정치 그룹 표시' : '추가 수정치 그룹 숨김') + '" aria-label="' + (sectionHidden ? '추가 수정치 그룹 표시' : '추가 수정치 그룹 숨김') + '">' + iconChevronDownSvg() + '</button>'
       + '</div></div>'
       + '<div class="extra-adj-body' + (sectionHidden ? ' off' : '') + '">'
       + groupsHtml
       + '</div>'
       + '</div>';
}

function renderExtraAdjEditor() {
  var host = document.getElementById('extra-adj-wrap');
  if (!host) return;
  host.innerHTML = buildExtraAdjEditor();
}

function toggleExtraAdjSectionHidden() {
  var st = ensureExtraAdj();
  st.sectionHidden = !st.sectionHidden;
  renderExtraAdjEditor();
  saveCurrentStateDebounced();
}

function addExtraAdjGroup() {
  var st = ensureExtraAdj();
  st.groups.push({
    name: '추가 수정치 ' + (st.groups.length + 1),
    enabled: true,
    hidden: false,
    rows: [{ field: 'achievement', value: '' }]
  });
  renderExtraAdjEditor();
  saveCurrentStateDebounced();
}

function removeExtraAdjGroup(groupIdx) {
  var st = ensureExtraAdj();
  if (groupIdx < 0 || groupIdx >= st.groups.length) return;
  st.groups.splice(groupIdx, 1);
  if (!st.groups.length) st.groups = defaultExtraAdj().groups;
  renderExtraAdjEditor();
  saveCurrentStateDebounced();
}

function setExtraAdjGroupEnabled(groupIdx, enabled) {
  var st = ensureExtraAdj();
  var group = st.groups[groupIdx];
  if (!group) return;
  group.enabled = !!enabled;
  renderExtraAdjEditor();
  saveCurrentStateDebounced();
}

function updateExtraAdjGroupName(groupIdx, name) {
  var st = ensureExtraAdj();
  var group = st.groups[groupIdx];
  if (!group) return;
  var nextName = String(name || '').trim();
  group.name = nextName || ('추가 수정치 ' + (groupIdx + 1));
  saveCurrentStateDebounced();
}

function addExtraAdjRow(groupIdx) {
  var st = ensureExtraAdj();
  var group = st.groups[groupIdx];
  if (!group) return;
  group.rows.push({ field: 'achievement', value: '' });
  renderExtraAdjEditor();
  saveCurrentStateDebounced();
}

function removeExtraAdjRow(groupIdx, idx) {
  var st = ensureExtraAdj();
  var group = st.groups[groupIdx];
  if (!group || idx < 0 || idx >= group.rows.length) return;
  group.rows.splice(idx, 1);
  if (!group.rows.length) group.rows.push({ field: 'achievement', value: '' });
  renderExtraAdjEditor();
  saveCurrentStateDebounced();
}

function updateExtraAdjRowField(groupIdx, idx, field) {
  var st = ensureExtraAdj();
  var group = st.groups[groupIdx];
  if (!group || !group.rows[idx]) return;
  group.rows[idx].field = resolveManualFieldUiSelection(field || 'achievement', 'flat');
  renderExtraAdjEditor();
  saveCurrentStateDebounced();
}

function updateExtraAdjRowValue(groupIdx, idx, value) {
  var st = ensureExtraAdj();
  var group = st.groups[groupIdx];
  if (!group || !group.rows[idx]) return;
  group.rows[idx].value = value || '';
  saveCurrentStateDebounced();
}

function closeCustomCombos() {
  document.querySelectorAll('.ccombo.open').forEach(function(c) { c.classList.remove('open'); });
}

function setCustomComboValue(inputId, value) {
  var input = document.getElementById(inputId);
  if (!input) return false;
  var combo = document.querySelector('.ccombo[data-input="' + inputId + '"]');
  if (!combo) { input.value = value; return true; }

  var opts = combo.querySelectorAll('.ccombo-opt');
  var found = false;
  opts.forEach(function(opt) {
    var isOn = opt.getAttribute('data-value') === value;
    opt.classList.toggle('on', isOn);
    if (isOn) found = true;
  });
  if (!found) return false;

  input.value = value;
  var btn = combo.querySelector('.ccombo-btn');
  if (btn) btn.textContent = value;
  return true;
}

function buildEfList(effects, prefix) {
  var html = '';
  effects.forEach(function(ef, i) {
    var key = getEffectStableKey(prefix, ef, i);
    ensureEffectBaseText(ef);
    var erNum = parseFloat(ef.erosion);
    html += '<div class="ef-item" id="ei-' + key + '">'
          + '<div class="ef-hd" id="hd-' + key + '">'
          + '<input type="checkbox" class="ef-chk" id="chk-' + key + '" data-key="' + key + '">'
          + '<span class="ef-nm" title="' + esc(ef.name) + '">' + esc(ef.name) + '</span>'
          + (ef.lv !== '' && ef.lv !== null ? '<span class="ef-lv">Lv' + ef.lv + '</span>' : '')
          + (ef.timing && ef.timing !== '-' ? '<span class="ef-tm">' + esc(ef.timing) + '</span>' : '')
          + (!isNaN(erNum) ? '<span class="ef-er">+' + erNum + '</span>' : '')
          + '</div>'
          + '<div class="ef-dtl" id="dtl-' + key + '">'
          + '<div id="ef-tags-' + key + '">' + buildEfTags(ef, key) + '</div>'
          + buildEffectTextEditorHtml(key)
          + '<div class="ef-preview" id="ef-preview-' + key + '">'
          + renderPreviewBox(key, '<div class="ef-preview-empty">\uc790\ub3d9 \uc778\uc2dd \ub300\uae30 \uc911...</div>')
          + '</div>'
          + '<div class="ef-manual" id="ef-manual-' + key + '">'
          + buildManualEditorHtml(key)
          + '</div>'
          + '</div></div>';
  });
  return html;
}
function buildEfTags(ef, key) {
  var erNum = parseFloat(ef.erosion);
  var tags = '';
  var lvExOn = isLvBonusExceptionEnabled(key, ef);
  if (ef.feature && ef.feature !== '-') tags += mkttag('\uae30\ub2a5', ef.feature);
  if (ef.diff    && ef.diff    !== '-') tags += mkttag('\ub09c\uc774\ub3c4', ef.diff);
  if (ef.target  && ef.target  !== '-') tags += mkttag('\ub300\uc0c1', ef.target);
  if (ef.range   && ef.range   !== '-') tags += mkttag('\uc0ac\uc815', ef.range);
  if (!isNaN(erNum)) tags += mkttag('\uce68\uc2dd', '+' + erNum);
  if (ef.limit   && ef.limit   !== '-') tags += mkttag('\uc81c\ud55c', formatLimitText(ef.limit));
  tags += '<button type="button" class="ttag ttag-btn ef-lvex' + (lvExOn ? ' on' : '') + '" data-key="' + key + '" title="이 이펙트는 침식률로 레벨업하지 않습니다. 켜면 자동 LV 보너스를 무시합니다." aria-pressed="' + (lvExOn ? 'true' : 'false') + '"><span class="ttag-chk" aria-hidden="true"><span class="ttag-chk-mark"></span></span><span>LV+ 예외</span></button>';
  return '<div class="ef-tags">' + tags + '</div>';
}
function renderEffectTags(key) {
  var node = document.getElementById('ef-tags-' + key);
  var ef = getEffectByKey(key);
  if (!node || !ef) return;
  node.innerHTML = buildEfTags(ef, key);
}
var TTAG_TOOLTIPS = {
  '기능': '이 이펙트가 속한 기능 분류 (예: 공격, 방어, 서포트)',
  '난이도': '이펙트 사용 판정의 난이도 수치',
  '대상': '이 이펙트의 효과가 미치는 대상 범위',
  '사정': '이펙트가 닿는 최대 거리 (근접·시야·범위 등)',
  '침식': '이 이펙트를 사용할 때 증가하는 침식률',
  '제한': '사용 횟수 제한 (씬·라운드·시나리오 단위)'
};
function mkttag(l, v) {
  var tip = TTAG_TOOLTIPS[l] ? ' title="' + esc(TTAG_TOOLTIPS[l]) + '"' : '';
  return '<div class="ttag"' + tip + '>' + l + ': <span>' + esc(v) + '</span></div>';
}
function formatLimitText(raw) {
  var s = String(raw == null ? '' : raw).trim();
  if (!s || s === '-') return s;
  if (/%/.test(s)) return s;
  if (/^0?\.\d+$/.test(s) || /^1(?:\.0+)?$/.test(s)) {
    var n = parseFloat(s);
    if (!isNaN(n)) {
      var pct = Math.round(n * 1000) / 10;
      return (pct % 1 === 0 ? String(pct.toFixed(0)) : String(pct)) + '%';
    }
  }
  return s;
}

function isEquipChecked(type, key) {
  if (!EQSEL || !EQSEL[type]) return true;
  return EQSEL[type][key] !== false;
}

function eqChkHtml(type, item, idx) {
  var key = getItemStableKey(type, item, idx);
  return '<input type="checkbox" class="eq-chk" data-type="' + type + '" data-key="' + key + '"' + (isEquipChecked(type, key) ? ' checked' : '') + '>';
}

function isEffectTextDirty(key) {
  var ef = getEffectByKey(key);
  if (!ef) return false;
  return String(ef.effect || '') !== ensureEffectBaseText(ef);
}

function syncEffectTextEditorUi(key) {
  var ef = getEffectByKey(key);
  if (!ef) return;
  var textarea = document.getElementById('ef-text-' + key);
  if (textarea && textarea.value !== String(ef.effect || '')) textarea.value = String(ef.effect || '');
  var resetBtn = document.querySelector('.ef-text-reset[data-key="' + key + '"]');
  if (resetBtn) resetBtn.classList.toggle('on', isEffectTextDirty(key));
}

function hasEffectTextEdits() {
  if (!G) return false;
  var dirty = false;
  [G.extraEffects || [], G.effects || []].forEach(function(list) {
    list.forEach(function(ef) {
      if (!dirty && String((ef || {}).effect || '') !== ensureEffectBaseText(ef)) dirty = true;
    });
  });
  return dirty;
}

function buildEffectTextEditorHtml(key) {
  var ef = getEffectByKey(key);
  if (!ef) return '';
  var dirty = isEffectTextDirty(key);
  return '<div class="ef-text-editor">'
       + '<div class="ef-text-head">'
       + '<div class="ef-text-title">이펙트 내용</div>'
       + '<button type="button" class="ef-text-reset' + (dirty ? ' on' : '') + '" data-key="' + key + '" title="원문 복원" aria-label="원문 복원">' + iconResetSvg() + '</button>'
       + '</div>'
       + '<textarea class="ef-textarea" id="ef-text-' + key + '" data-key="' + key + '" spellcheck="false">' + esc(String(ef.effect || '')) + '</textarea>'
       + '</div>';
}

function buildEffectTextState(selKeys) {
  var out = {};
  if (!G) return out;
  var keys = selKeys ? normalizeEffectKeyList(selKeys) : [];
  if (!keys.length) {
    [G.extraEffects || [], G.effects || []].forEach(function(list, listIdx) {
      var prefix = listIdx === 0 ? 'ex' : 'ef';
      list.forEach(function(ef, idx) {
        keys.push(getEffectStableKey(prefix, ef, idx));
      });
    });
  }
  keys.forEach(function(key) {
    var ef = getEffectByKey(key);
    if (!ef) return;
    var base = ensureEffectBaseText(ef);
    var current = String(ef.effect || '');
    if (current !== base) out[key] = current;
  });
  return out;
}

function resetAllEffectTextsToBase() {
  if (!G) return;
  [G.extraEffects || [], G.effects || []].forEach(function(list) {
    list.forEach(function(ef) {
      if (!ef) return;
      ef.effect = ensureEffectBaseText(ef);
    });
  });
}

function applyEffectTextState(rawState) {
  var applied = false;
  var src = (rawState && typeof rawState === 'object') ? rawState : {};
  Object.keys(src).forEach(function(rawKey) {
    var key = resolveEffectKeyFromState(rawKey);
    var ef = getEffectByKey(key);
    if (!ef) return;
    ef.effect = String(src[rawKey] || '');
    syncEffectConditionState(key, ef);
    applied = true;
  });
  return applied;
}

function withSnapshotEffectTexts(snapshot, fn) {
  var snap = (snapshot && typeof snapshot === 'object') ? snapshot : {};
  var keys = normalizeEffectKeyList(snap.selKeys);
  if (!keys.length) return fn();
  var src = (snap.effectTexts && typeof snap.effectTexts === 'object') ? snap.effectTexts : {};
  var effectTextMap = {};
  Object.keys(src).forEach(function(rawKey) {
    var key = resolveEffectKeyFromState(rawKey);
    if (!key) return;
    effectTextMap[key] = String(src[rawKey] || '');
  });
  var touched = [];
  keys.forEach(function(key) {
    var ef = getEffectByKey(key);
    if (!ef) return;
    var base = ensureEffectBaseText(ef);
    touched.push({ ef: ef, effect: ef.effect });
    ef.effect = Object.prototype.hasOwnProperty.call(effectTextMap, key) ? effectTextMap[key] : base;
  });
  try {
    return fn();
  } finally {
    touched.forEach(function(entry) {
      entry.ef.effect = entry.effect;
    });
  }
}

function syncEffectConditionState(key, ef) {
  var state = ensureCondState(key, ef);
  var detected = detectEffectConditions(ef || getEffectByKey(key));
  var values = {};
  detected.forEach(function(condKey) {
    values[condKey] = !!state.values[condKey];
  });
  state.values = values;
}

function applyEffectTextChange(key, value) {
  var ef = getEffectByKey(key);
  if (!ef) return;
  ef.effect = String(value || '');
  syncEffectTextEditorUi(key);
  syncEffectConditionState(key, ef);
  resetEffectDerivedCaches();
  renderEffectTags(key);
  markPreviewDirty(key);
  if (SEL.some(function(s) { return s.key === key; })) autoParamFromSelectedEffects();
  recalcDebounced();
}

function resetEffectText(key) {
  var ef = getEffectByKey(key);
  if (!ef) return;
  applyEffectTextChange(key, ensureEffectBaseText(ef));
}

function ensureCustomState(key) {
  if (!EF_CUSTOM[key]) EF_CUSTOM[key] = { manual: [], manualOpen: true, previewOpen: true };
  if (!EF_CUSTOM[key].manual) EF_CUSTOM[key].manual = [];
  EF_CUSTOM[key].manual = EF_CUSTOM[key].manual.map(function(rule) {
    var next = (rule && typeof rule === 'object') ? rule : {};
    var normalized = normalizeManualRuleStorage(next.field || 'dice', next.expr || '');
    return {
      field: normalized.field,
      expr: normalized.expr
    };
  });
  if (!EF_CUSTOM[key].autoExclude || typeof EF_CUSTOM[key].autoExclude !== 'object') EF_CUSTOM[key].autoExclude = {};
  if (EF_CUSTOM[key].autoExclude.hit) EF_CUSTOM[key].autoExclude.achievement = true;
  if (EF_CUSTOM[key].autoExclude.hitDice) EF_CUSTOM[key].autoExclude.achievementDice = true;
  if (Object.prototype.hasOwnProperty.call(EF_CUSTOM[key].autoExclude, 'hit')) delete EF_CUSTOM[key].autoExclude.hit;
  if (Object.prototype.hasOwnProperty.call(EF_CUSTOM[key].autoExclude, 'hitDice')) delete EF_CUSTOM[key].autoExclude.hitDice;
  if (!EF_CUSTOM[key].hiddenCond || typeof EF_CUSTOM[key].hiddenCond !== 'object') EF_CUSTOM[key].hiddenCond = {};
  if (typeof EF_CUSTOM[key].manualOpen !== 'boolean') EF_CUSTOM[key].manualOpen = true;
  if (typeof EF_CUSTOM[key].previewOpen !== 'boolean') EF_CUSTOM[key].previewOpen = true;
  if (!Array.isArray(EF_CUSTOM[key].manualTags)) EF_CUSTOM[key].manualTags = [];
  EF_CUSTOM[key].manualTags.forEach(function(t) {
    var customText = getManualCustomTagText(t);
    if (!customText) return;
    EF_CUSTOM[key].manual.push({ field: MANUAL_TAG_CUSTOM, expr: customText });
  });
  EF_CUSTOM[key].manualTags = EF_CUSTOM[key].manualTags.filter(function(t) {
    if (!t || typeof t !== 'object') return false;
    if (t.tag === MANUAL_TAG_CUSTOM) return false;
    return !!t.tag;
  });
  return EF_CUSTOM[key];
}

function isLvBonusExceptionEnabled(key, ef) {
  var state = ensureCustomState(key);
  if (typeof state.lvBonusOff === 'boolean') return state.lvBonusOff;
  return isNoErosionLevelUp((ef || {}).effect);
}

function toggleLvBonusException(key) {
  var ef = getEffectByKey(key);
  if (!ef) return;
  var state = ensureCustomState(key);
  state.lvBonusOff = !isLvBonusExceptionEnabled(key, ef);
  saveCurrentStateDebounced();
}

function isAutoExcluded(key, field) {
  var state = ensureCustomState(key);
  return !!(state.autoExclude && state.autoExclude[field]);
}

function toggleAutoExcludeField(key, field) {
  var state = ensureCustomState(key);
  state.autoExclude[field] = !state.autoExclude[field];
  saveCurrentStateDebounced();
}

function resetAutoExcludeFields(key) {
  var state = ensureCustomState(key);
  state.autoExclude = {};
  saveCurrentStateDebounced();
}

function isCondTagHidden(key, condKey) {
  var state = ensureCustomState(key);
  return !!(state.hiddenCond && state.hiddenCond[condKey]);
}

function toggleCondTagHidden(key, condKey) {
  var state = ensureCustomState(key);
  state.hiddenCond[condKey] = !state.hiddenCond[condKey];
  saveCurrentStateDebounced();
}

function resetHiddenCondTags(key) {
  var state = ensureCustomState(key);
  state.hiddenCond = {};
  saveCurrentStateDebounced();
}

function resetAutoPreviewFilters(key) {
  var state = ensureCustomState(key);
  state.autoExclude = {};
  state.hiddenCond = {};
  saveCurrentStateDebounced();
}

function applyAutoExcludes(key, autoMods) {
  var state = ensureCustomState(key);
  var ex = state.autoExclude || {};
  ['critical','hit','achievement','dice','attack','guard','armor','initiative','hp','hpDamage','hpDice','hpDamageDice','hitDice','achievementDice'].forEach(function(field) {
    if (ex[field]) autoMods[field] = 0;
  });
  if (ex.criticalFloor) autoMods.criticalFloor = null;
  if (ex.dicePenaltyImmune) autoMods.dicePenaltyImmune = false;
  if (ex.initiativeFixedZero) autoMods.initiativeFixedZero = false;
  if (ex.reactionForbidden) autoMods.reactionForbidden = false;
  if (ex.dodgeForbidden) autoMods.dodgeForbidden = false;
  if (ex.dodgeNoDiceRoll) autoMods.dodgeNoDiceRoll = false;
  if (ex.guardForbidden) autoMods.guardForbidden = false;
  if (ex.coverForbidden) autoMods.coverForbidden = false;
  if (ex.coverAsNoGuard) autoMods.coverAsNoGuard = false;
  if (ex.ignoreTargetArmor) autoMods.ignoreTargetArmor = false;
  if (ex.clearFlight) autoMods.clearFlight = false;
  if (ex.sameEngageForbidden) autoMods.sameEngageForbidden = false;
  if (ex.sameEngageAllowed) autoMods.sameEngageAllowed = false;
  if (ex.rangeOverride) autoMods.rangeOverride = null;
  if (ex.targetOverride) autoMods.targetOverride = null;
}

function getManualFieldLabel(field) {
  field = normalizeBonusFieldKey(field);
  var found = null;
  MANUAL_FIELD_OPTIONS.forEach(function(opt) {
    if (opt.value === field) found = opt.label;
  });
  return found || field;
}
function getManualFieldDesc(field) {
  field = normalizeBonusFieldKey(field);
  var found = null;
  MANUAL_FIELD_OPTIONS.forEach(function(opt) {
    if (opt.value === field) found = opt.desc || null;
  });
  return found;
}

function buildManualFieldOptionsHtml(selected) {
  selected = normalizeBonusFieldKey(selected);
  var html = '';
  MANUAL_FIELD_OPTIONS.forEach(function(opt) {
    html += '<option value="' + opt.value + '"' + (opt.value === selected ? ' selected' : '') + '>' + opt.label + '</option>';
  });
  return html;
}
function buildManualFieldGroupOptionsHtml(selected) {
  var group = getManualFieldUiGroup(selected);
  var selectedValue = (group && group.value) || 'dice';
  var html = '';
  MANUAL_FIELD_UI_GROUPS.forEach(function(opt) {
    html += '<option value="' + opt.value + '"' + (opt.value === selectedValue ? ' selected' : '') + '>' + opt.label + '</option>';
  });
  return html;
}
function buildExtraAdjFieldGroupOptionsHtml(selected) {
  var group = getManualFieldUiGroup(selected);
  var selectedValue = (group && group.value) || 'dice';
  var html = '';
  MANUAL_FIELD_UI_GROUPS.forEach(function(opt) {
    if (opt.value === 'manualTag') return;
    html += '<option value="' + opt.value + '"' + (opt.value === selectedValue ? ' selected' : '') + '>' + opt.label + '</option>';
  });
  return html;
}
function buildManualFieldKindOptionsHtml(selected) {
  var group = getManualFieldUiGroup(selected);
  var mode = getManualFieldUiMode(selected);
  if (!group) group = MANUAL_FIELD_UI_GROUPS[0] || { flat: 'dice' };
  var flatLabel = (group.value === 'manualTag') ? '태그' : '수치';
  var html = '<option value="flat"' + (mode === 'flat' ? ' selected' : '') + '>' + flatLabel + '</option>';
  if (group.dice) html += '<option value="dice"' + (mode === 'dice' ? ' selected' : '') + '>D</option>';
  return html;
}

function buildManualTagOptionsHtml(selected) {
  var html = '';
  MANUAL_TAG_OPTIONS.forEach(function(opt) {
    html += '<option value="' + opt.value + '"' + (opt.value === selected ? ' selected' : '') + '>' + opt.label + '</option>';
  });
  return html;
}

function getManualTagDisplayText(tag) {
  var customText = getManualCustomTagText(tag);
  if (customText) return customText;
  var opt = getManualTagOption(tag && tag.tag);
  if (!opt) return '';
  if (opt.hasValue) return String(tag.value || '').trim() || '-';
  return '적용';
}

function buildManualEditorHtml(key) {
  var state = ensureCustomState(key);
  var isOpen = state.manualOpen;
  var rows = '';
  state.manual.forEach(function(rule, idx) {
    var isTagRule = (normalizeBonusFieldKey(rule.field || 'dice') === MANUAL_TAG_CUSTOM);
    var exprPlaceholder = isTagRule ? '직접 입력한 텍스트를 태그로 추가' : 'LV*10 또는 LV+1D';
    var exprValue = getManualRuleDisplayExpr(rule.field || 'dice', rule.expr || '');
    rows += '<div class="ef-man-row ef-man-rule-row">'
         + '<select class="ef-man-field" data-key="' + key + '" data-row="' + idx + '">' + buildManualFieldGroupOptionsHtml(rule.field || 'dice') + '</select>'
         + '<input class="ef-man-expr" data-key="' + key + '" data-row="' + idx + '" value="' + esc(exprValue) + '" placeholder="' + esc(exprPlaceholder) + '">'
         + '<button type="button" class="ef-man-del" data-key="' + key + '" data-row="' + idx + '">\u2212</button>'
         + '</div>';
  });
  var legacyTagRows = '';
  state.manualTags.forEach(function(t, idx) {
    var tagLabel = ((getManualTagOption(t.tag) || {}).label) || t.tag;
    legacyTagRows += '<div class="ef-man-row">'
            + '<span class="ef-mtag-label">' + esc(tagLabel) + '</span>'
            + '<span class="ef-mtag-pill">' + esc(getManualTagDisplayText(t)) + '</span>'
            + '<button type="button" class="ef-mtag-del" data-key="' + key + '" data-tidx="' + idx + '">\u2212</button>'
            + '</div>';
  });
  return '<div class="ef-man-hd">'
       + '<button type="button" class="ef-man-tgl' + (isOpen ? ' open' : '') + '" data-key="' + key + '" title="자동 파싱으로 잡히지 않는 수치를 직접 입력해 보정합니다">\uc218\ub3d9 \ubcf4\uc815 <span class="txt-ico">' + iconChevronDownSvg() + '</span></button>'
       + '<button type="button" class="ef-man-add icon-btn" data-key="' + key + '" title="보정 항목 추가" aria-label="추가">' + iconPlusSvg() + '</button>'
       + '</div>'
       + '<div class="ef-man-body' + (isOpen ? '' : ' off') + '">'
       + (rows || '<div class="ef-man-empty">자동 인식되지 않은 수치를 필드와 식으로 입력하세요.</div>')
       + '<div class="ef-preview-row" style="margin-top:4px;color:#919191">식 예시: LV*10 &nbsp;·&nbsp; LV+2 &nbsp;·&nbsp; LV+1D &nbsp;·&nbsp; 3D</div>'
       + (legacyTagRows
          ? '<div class="ef-mtag-hd" style="margin-top:6px;display:flex;align-items:center;gap:6px"><span style="font-size:10px;color:#b7b7b7">기존 특수 태그</span></div>' + legacyTagRows
          : '')
       + '</div>';
}

function getCondPatternByKey(key) {
  var out = null;
  COND_PATTERNS.forEach(function(p) { if (p.key === key) out = p; });
  return out;
}

function detectEffectConditions(srcOrEf) {
  var text = '';
  if (typeof srcOrEf === 'string') text = srcOrEf;
  else text = (srcOrEf || {}).effect || '';
  var src = normalizeEffectText(text);
  if (!src) return [];
  var keys = [];
  COND_PATTERNS.forEach(function(p) {
    if (p.re.test(src)) keys.push(p.key);
  });
  return keys;
}

function ensureCondState(key, ef) {
  if (!EF_COND[key]) EF_COND[key] = { open: false, values: {} };
  if (typeof EF_COND[key].open !== 'boolean') EF_COND[key].open = false;
  if (!EF_COND[key].values || typeof EF_COND[key].values !== 'object') EF_COND[key].values = {};
  var detected = detectEffectConditions(ef || getEffectByKey(key));
  detected.forEach(function(condKey) {
    if (typeof EF_COND[key].values[condKey] !== 'boolean') EF_COND[key].values[condKey] = false;
  });
  return EF_COND[key];
}


function updateConditionValue(key, condKey, checked) {
  var state = ensureCondState(key);
  state.values[condKey] = !!checked;
  saveCurrentStateDebounced();
}

function getCondStateSignature(key) {
  var state = EF_COND[key];
  if (!state || !state.values || typeof state.values !== 'object') return '';
  var onKeys = [];
  Object.keys(state.values).sort().forEach(function(condKey) {
    if (state.values[condKey]) onKeys.push(condKey);
  });
  return onKeys.join(',');
}

function getManualStateSignature(key) {
  var state = EF_CUSTOM[key];
  if (!state || typeof state !== 'object') return '';
  var rows = [];
  var manual = Array.isArray(state.manual) ? state.manual : [];
  manual.forEach(function(rule) {
    rows.push(String((rule && rule.field) || '') + ':' + String((rule && rule.expr) || ''));
  });
  var lvOff = (typeof state.lvBonusOff === 'boolean') ? (state.lvBonusOff ? '1' : '0') : '';
  var tagRows = [];
  var tags = Array.isArray(state.manualTags) ? state.manualTags : [];
  tags.forEach(function(t) {
    tagRows.push(String((t && t.tag) || '') + '=' + String((t && t.value) || ''));
  });
  return rows.join('|') + '#lvOff:' + lvOff + '#tags:' + tagRows.join('|');
}

function getAutoExcludeSignature(key) {
  var state = EF_CUSTOM[key];
  if (!state || !state.autoExclude || typeof state.autoExclude !== 'object') return '';
  var fields = [];
  Object.keys(state.autoExclude).sort().forEach(function(field) {
    if (state.autoExclude[field]) fields.push(field);
  });
  return fields.join(',');
}

function buildEffectParseCacheKey(effectKey, ef, effectLv) {
  var key = String(effectKey || '');
  return key + '|'
    + String(effectLv || 0) + '|'
    + normalizeEffectText((ef || {}).effect) + '|'
    + getCondStateSignature(key) + '|'
    + getManualStateSignature(key) + '|'
    + getAutoExcludeSignature(key);
}

function setEffectParseCacheEntry(cacheKey, value) {
  if (!Object.prototype.hasOwnProperty.call(_EFFECT_PARSE_CACHE, cacheKey)) _EFFECT_PARSE_CACHE_SIZE += 1;
  _EFFECT_PARSE_CACHE[cacheKey] = value;
  if (_EFFECT_PARSE_CACHE_SIZE <= _EFFECT_PARSE_CACHE_LIMIT) return;
  _EFFECT_PARSE_CACHE = {};
  _EFFECT_PARSE_CACHE_SIZE = 0;
}

function getEffectParsedBundle(effectKey, ef, effectLv) {
  ensureCondState(effectKey, ef);
  var cacheKey = buildEffectParseCacheKey(effectKey, ef, effectLv);
  if (Object.prototype.hasOwnProperty.call(_EFFECT_PARSE_CACHE, cacheKey)) return _EFFECT_PARSE_CACHE[cacheKey];
  var text = normalizeEffectText((ef || {}).effect);
  var autoMods = analyzeEffectDelta(ef, effectLv);
  applyAutoExcludes(effectKey, autoMods);
  var manualMods = getManualModsForEffect(effectKey, effectLv);
  var bundle = { text: text, autoMods: autoMods, manualMods: manualMods };
  setEffectParseCacheEntry(cacheKey, bundle);
  return bundle;
}

function blankEffectNumericMods() {
  return {
    critical: 0,
    hit: 0,
    achievement: 0,
    dice: 0,
    attack: 0,
    guard: 0,
    armor: 0,
    initiative: 0,
    hp: 0,
    hpDamage: 0,
    erosion: 0,
    hpDice: 0,
    hpDamageDice: 0,
    attackDice: 0,
    guardDice: 0,
    armorDice: 0,
    hitDice: 0,
    achievementDice: 0,
    initDice: 0,
    critDice: 0
  };
}

function getNumericModsFromText(text, effectLv) {
  var mods = blankEffectNumericMods();
  var critSplit = extractStatDeltaSplit(text, '\ud06c\ub9ac\ud2f0\uceec\uce58', effectLv);
  mods.critical = critSplit.flat; mods.critDice = critSplit.dice;
  var hitSplit = extractHitDeltaSplit(text, effectLv);
  var achSplit = extractAchievementDeltaSplit(text, effectLv);
  mods.achievement = hitSplit.flat + achSplit.flat;
  mods.achievementDice = hitSplit.dice + achSplit.dice;
  mods.dice = extractStatDelta(text, '\ub2e4\uc774\uc2a4', effectLv);
  var atkSplit = extractStatDeltaSplit(text, '\uacf5\uaca9\ub825', effectLv);
  mods.attack = atkSplit.flat; mods.attackDice = atkSplit.dice;
  var dmgSplit = extractDamageRollDeltaSplit(text, effectLv);
  mods.attack += dmgSplit.flat; mods.attackDice += dmgSplit.dice;
  var grdSplit = extractStatDeltaSplit(text, '\uac00\ub4dc\uce58', effectLv);
  mods.guard = grdSplit.flat; mods.guardDice = grdSplit.dice;
  var armSplit = extractStatDeltaMultiSplit(text, ['\uc7a5\uac11\uce58', '\uc7a5\uac11(?!\uce58)'], effectLv);
  mods.armor = armSplit.flat; mods.armorDice = armSplit.dice;
  var initSplit = extractStatDeltaMultiSplit(text, ['\ud589\ub3d9\uce58', '\ud589\ub3d9(?!\uce58)'], effectLv);
  mods.initiative = initSplit.flat; mods.initDice = initSplit.dice;
  mods.hp = extractHpRecoverDelta(text, effectLv) - extractHpConsumeDelta(text, effectLv);
  mods.hpDamage = extractHpDamageDelta(text, effectLv);
  mods.hpDice = extractHpRecoverDiceCount(text, effectLv) - extractHpConsumeDiceCount(text, effectLv);
  mods.hpDamageDice = extractHpDamageDiceCount(text, effectLv);
  return mods;
}

function mergeNumericMods(dst, src, sign) {
  var mul = (sign === -1) ? -1 : 1;
  ['critical','hit','achievement','dice','attack','guard','armor','initiative','hp','hpDamage','erosion','hpDice','hpDamageDice','attackDice','guardDice','armorDice','hitDice','achievementDice','initDice','critDice'].forEach(function(k) {
    dst[k] += (src[k] || 0) * mul;
  });
}

function getConditionalSnippetMods(text, effectLv) {
  var src = normalizeEffectText(text);
  var map = {};
  COND_PATTERNS.forEach(function(p) {
    var snippets = [];
    var re = p.snippetRe;
    if (!re) return;
    re.lastIndex = 0;
    var m;
    while ((m = re.exec(src)) !== null) {
      if (m[0]) snippets.push(m[0]);
    }
    if (!snippets.length) return;
    var total = blankEffectNumericMods();
    snippets.forEach(function(snippet) {
      var mods = getNumericModsFromText(snippet, effectLv);
      mergeNumericMods(total, mods, 1);
    });
    map[p.key] = total;
  });
  return map;
}

function applyInactiveConditionGates(key, text, effectLv, autoMods) {
  var state = ensureCondState(key);
  var condMods = getConditionalSnippetMods(text, effectLv);
  Object.keys(condMods).forEach(function(condKey) {
    if (state.values[condKey]) return;
    mergeNumericMods(autoMods, condMods[condKey], -1);
  });
}

function renderPreviewBox(key, bodyHtml) {
  var state = ensureCustomState(key);
  var isOpen = state.previewOpen;
  return '<div class="ef-prev-hd">'
       + '<button type="button" class="ef-prev-tgl' + (isOpen ? ' open' : '') + '" data-key="' + key + '">\uc790\ub3d9 \uc778\uc2dd <span class="txt-ico">' + iconChevronDownSvg() + '</span></button>'
       + '<div class="ef-prev-actions">'
       + '<button type="button" class="ef-prev-refresh" data-key="' + key + '" title="자동 인식 초기화" aria-label="자동 인식 초기화">'
       + iconResetSvg()
       + '</button>'
       + '</div>'
       + '</div>'
       + '<div class="ef-prev-body' + (isOpen ? '' : ' off') + '">' + bodyHtml + '</div>';
}

function togglePreviewBox(key) {
  var state = ensureCustomState(key);
  state.previewOpen = !state.previewOpen;
  markPreviewDirty(key);
  saveCurrentStateDebounced();
}

function renderManualEditor(key) {
  var box = document.getElementById('ef-manual-' + key);
  if (!box) return;
  box.innerHTML = buildManualEditorHtml(key);
  syncManualTagAddRowUi(box);
}

function toggleManualEditor(key) {
  var state = ensureCustomState(key);
  state.manualOpen = !state.manualOpen;
  renderManualEditor(key);
  saveCurrentStateDebounced();
}

function addManualRule(key) {
  var state = ensureCustomState(key);
  state.manualOpen = true;
  state.manual.push({ field: 'dice', expr: 'LV' });
  renderManualEditor(key);
  saveCurrentStateDebounced();
}

function removeManualRule(key, idx) {
  var state = ensureCustomState(key);
  if (idx < 0 || idx >= state.manual.length) return;
  state.manual.splice(idx, 1);
  renderManualEditor(key);
  saveCurrentStateDebounced();
}

function updateManualRuleField(key, idx, field) {
  var state = ensureCustomState(key);
  if (!state.manual[idx]) return;
  state.manual[idx].field = normalizeBonusFieldKey(field);
  saveCurrentStateDebounced();
}
function updateManualRuleGroupField(key, idx, groupValue) {
  var state = ensureCustomState(key);
  if (!state.manual[idx]) return;
  state.manual[idx].field = resolveManualFieldUiSelection(groupValue, 'flat');
  renderManualEditor(key);
  saveCurrentStateDebounced();
}
function updateManualRuleUiField(key, idx, groupValue, modeValue) {
  var state = ensureCustomState(key);
  if (!state.manual[idx]) return;
  state.manual[idx].field = resolveManualFieldUiSelection(groupValue, modeValue);
  renderManualEditor(key);
  saveCurrentStateDebounced();
}

function updateManualRuleExpr(key, idx, expr) {
  var state = ensureCustomState(key);
  if (!state.manual[idx]) return;
  state.manual[idx].expr = expr;
  saveCurrentStateDebounced();
}
function parseExtraAdjValue(rawValue) {
  var src = String(rawValue || '').trim();
  if (!src) return NaN;
  var stripped = stripManualDiceSuffix(src);
  if (!stripped) return NaN;
  var out = Number(stripped);
  return isNaN(out) ? NaN : out;
}

function addManualTag(key, tag, value) {
  var state = ensureCustomState(key);
  if (!getManualTagOption(tag)) {
    var customText = String(tag || value || '').trim();
    if (!customText) return false;
    state.manual.push({ field: MANUAL_TAG_CUSTOM, expr: customText });
    saveCurrentStateDebounced();
    clearEffectParseCache();
    renderManualEditor(key);
    recalc();
    return true;
  }
  var opt = getManualTagOption(tag);
  if (!opt) return false;
  var nextValue = String(value || '').trim();
  if (opt.hasValue && !nextValue) return false;
  state.manualTags.push({ tag: opt.value, value: opt.hasValue ? nextValue : '' });
  saveCurrentStateDebounced();
  clearEffectParseCache();
  renderManualEditor(key);
  recalc();
  return true;
}

function syncManualTagAddRowUi(scope) {
  var root = scope && scope.nodeType ? scope : document;
  var rows = root.matches && root.matches('.ef-mtag-add-row') ? [root] : root.querySelectorAll('.ef-mtag-add-row');
  Array.prototype.forEach.call(rows, function(row) {
    var input = row.querySelector('.ef-mtag-add-value');
    if (!input) return;
    input.disabled = false;
    input.placeholder = '직접 입력한 텍스트를 태그로 추가';
  });
}

function getEffectByKey(key) {
  if (!G) return null;
  return EFFECT_INDEX[String(key || '')] || null;
}

function getManualModsForEffect(key, effectLv) {
  var state = EF_CUSTOM[key];
  var out = {
    critical: 0,
    critDice: 0,
    hit: 0,
    achievement: 0,
    dice: 0,
    attack: 0,
    attackDice: 0,
    guard: 0,
    guardDice: 0,
    armor: 0,
    armorDice: 0,
    initiative: 0,
    initDice: 0,
    hp: 0,
    hpDamage: 0,
    erosion: 0,
    hpDice: 0,
    hpDamageDice: 0,
    achievementDice: 0,
    criticalFloor: null,
    tags: {}
  };
  if (!state) return out;
  if (state.manual && state.manual.length) {
    state.manual.forEach(function(rule) {
      var field = resolveManualRuleField(rule.field || 'dice', rule.expr || '');
      var val = parseLvToken(rule.expr || '', effectLv);
      if (isNaN(val)) return;
      if (field === 'criticalFloor') {
        out.criticalFloor = (out.criticalFloor === null) ? val : Math.min(out.criticalFloor, val);
        return;
      }
      if (Object.prototype.hasOwnProperty.call(out, field)) out[field] += val;
    });
  }
  if (state.manualTags && state.manualTags.length) {
    state.manualTags.forEach(function(t) {
      if (!t || !t.tag) return;
      if (t.tag === MANUAL_TAG_CUSTOM) return;
      var opt = getManualTagOption(t.tag);
      if (!opt) return;
      if (opt.hasValue) {
        if (t.tag === 'criticalFloor') {
          var v = parseInt(t.value, 10);
          if (!isNaN(v)) out.criticalFloor = (out.criticalFloor === null) ? v : Math.min(out.criticalFloor, v);
        } else {
          out.tags[t.tag] = String(t.value || '').trim();
        }
      } else {
        out.tags[t.tag] = true;
      }
    });
  }
  return out;
}

function analyzeEffectDelta(ef, effectLv) {
  var txt = normalizeEffectText((ef || {}).effect);
  var critSplit = extractStatDeltaSplit(txt, '\ud06c\ub9ac\ud2f0\uceec\uce58', effectLv);
  var hitSplit = extractHitDeltaSplit(txt, effectLv);
  var achSplit = extractAchievementDeltaSplit(txt, effectLv);
  var atkSplit = extractStatDeltaSplit(txt, '\uacf5\uaca9\ub825', effectLv);
  var dmgSplit = extractDamageRollDeltaSplit(txt, effectLv);
  var grdSplit = extractStatDeltaSplit(txt, '\uac00\ub4dc\uce58', effectLv);
  var armSplit = extractStatDeltaMultiSplit(txt, ['\uc7a5\uac11\uce58', '\uc7a5\uac11(?!\uce58)'], effectLv);
  var initSplit = extractStatDeltaMultiSplit(txt, ['\ud589\ub3d9\uce58', '\ud589\ub3d9(?!\uce58)'], effectLv);
  return {
    appliedLv: effectLv,
    critical: critSplit.flat,
    critDice: critSplit.dice,
    hit: 0,
    hitDice: 0,
    achievement: hitSplit.flat + achSplit.flat,
    achievementDice: hitSplit.dice + achSplit.dice,
    dice: extractStatDelta(txt, '\ub2e4\uc774\uc2a4', effectLv),
    attack: atkSplit.flat + dmgSplit.flat,
    attackDice: atkSplit.dice + dmgSplit.dice,
    guard: grdSplit.flat,
    guardDice: grdSplit.dice,
    armor: armSplit.flat,
    armorDice: armSplit.dice,
    initiative: initSplit.flat,
    initDice: initSplit.dice,
    hp: extractHpRecoverDelta(txt, effectLv) - extractHpConsumeDelta(txt, effectLv),
    hpDamage: extractHpDamageDelta(txt, effectLv),
    hpDice: extractHpRecoverDiceCount(txt, effectLv) - extractHpConsumeDiceCount(txt, effectLv),
    hpDamageDice: extractHpDamageDiceCount(txt, effectLv),
    criticalFloor: extractCriticalFloor(txt),
    dicePenaltyImmune: hasDicePenaltyImmunity(txt),
    initiativeFixedZero: hasPriorityInitiativeZero(txt),
    reactionForbidden: hasReactionForbidden(txt),
    dodgeForbidden: hasDodgeForbidden(txt),
    dodgeNoDiceRoll: hasDodgeDiceRollForbidden(txt),
    guardForbidden: hasGuardForbidden(txt),
    coverForbidden: hasCoverForbidden(txt),
    coverAsNoGuard: hasCoverAsNoGuard(txt),
    ignoreTargetArmor: hasIgnoreTargetArmor(txt),
    clearFlight: hasClearFlight(txt),
    sameEngageForbidden: hasSameEngageForbidden(txt),
    sameEngageAllowed: hasSameEngageAllowed(txt),
    rangeOverride: (function() {
      var r = parseRangeTagFromText(txt) || parseRangeChangeFromText(txt);
      return r ? r.value : null;
    })(),
    targetOverride: (function() {
      var t = parseTargetTagFromText(txt) || parseTargetChangeFromText(txt);
      return t ? t.value : null;
    })()
  };
}

function renderEffectPreview(key) {
  var box = document.getElementById('ef-preview-' + key);
  if (!box) return;
  var ef = getEffectByKey(key);
  if (!ef) {
    box.innerHTML = renderPreviewBox(key, '<div class="ef-preview-empty">\uc774\ud399\ud2b8\ub97c \ucc3e\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4.</div>');
    return;
  }
  var er = +((document.getElementById('inp-er') || {}).value || 0);
  var effLv = calcEL(er);
  var appliedLv = getAppliedEffectLv(key, ef, effLv);
  var parsedBundle = getEffectParsedBundle(key, ef, appliedLv);
  var parsed = parsedBundle.autoMods;
  var manual = parsedBundle.manualMods;
  var rows = [];
  var hasAuto = false;

  var detectedConds = detectEffectConditions(ef);
  if (detectedConds.length) {
    var condTags = detectedConds.map(function(condKey) {
      var p = getCondPatternByKey(condKey);
      if (!p) return '';
      if (isCondTagHidden(key, condKey)) return '';
      return '<div class="ttag ttag-cond"><span>' + esc(p.label) + '</span><button type="button" class="ttag-cond-hide" data-key="' + key + '" data-cond="' + condKey + '" title="태그 숨김" aria-label="태그 숨김">×</button></div>';
    }).filter(function(tag) { return !!tag; }).join('');
    if (condTags) rows.push('<div class="ef-cond-tags">' + condTags + '</div>');
  }
  rows.push('<div class="ef-preview-row">Lv: ' + (+ef.lv || 0) + (effLv > 0 ? ' \u2192 ' + appliedLv : '') + '</div>');
  ['critical','hit','achievement','dice','attack','guard','armor','initiative','hp','hpDamage','erosion','hpDice','hpDamageDice','attackDice','guardDice','armorDice','hitDice','achievementDice','initDice','critDice'].forEach(function(field) {
    var autoVal = parsed[field] || 0;
    var manualVal = manual[field] || 0;
    if (autoVal === 0 && manualVal === 0) return;
    if (autoVal !== 0 || isAutoExcluded(key, field)) hasAuto = true;
    var isDiceField = (field === 'hpDice' || field === 'hpDamageDice' || field === 'attackDice' || field === 'guardDice' || field === 'armorDice' || field === 'hitDice' || field === 'achievementDice' || field === 'initDice' || field === 'critDice');
    var fmt = isDiceField ? fmtDiceSigned : fmtSigned;
    var cutBtn = '';
    if (autoVal !== 0 || isAutoExcluded(key, field)) {
      var isCut = isAutoExcluded(key, field);
      cutBtn = ' <button type="button" class="ef-auto-cut' + (isCut ? ' on' : '') + '" data-key="' + key + '" data-field="' + field + '" title="' + (isCut ? '제외 해제' : '자동 인식 제외') + '" aria-label="' + (isCut ? '제외 해제' : '자동 인식 제외') + '">×</button>';
    }
    var fieldDesc = getManualFieldDesc(field);
    var fieldTip = fieldDesc ? ' title="' + esc(fieldDesc) + '"' : '';
    rows.push('<div class="ef-preview-row"><span' + fieldTip + '>' + getManualFieldLabel(field) + '</span>: ' + fmt(autoVal + manualVal)
      + ' <span style="color:#8f8f8f">(자동 ' + fmt(autoVal) + ' / 수동 ' + fmt(manualVal) + ')</span>' + cutBtn + '</div>');
  });
  var specialTags = [];
  function specialTag(field, label, rawActive, displayVal) {
    var exOn = isAutoExcluded(key, field);
    if (!rawActive || exOn) return;
    hasAuto = true;
    var hideBtn = '<button type="button" class="ttag-special-hide" data-key="' + key + '" data-field="' + field + '" title="자동 인식 제외" aria-label="자동 인식 제외">×</button>';
    var text = displayVal != null ? label + ': <span>' + esc(String(displayVal)) + '</span>' : '<span>' + label + '</span>';
    specialTags.push('<div class="ttag ttag-special">' + text + hideBtn + '</div>');
  }
  if (parsed.rangeOverride || isAutoExcluded(key, 'rangeOverride')) {
    specialTag('rangeOverride', '사정거리', parsed.rangeOverride, parsed.rangeOverride || '-');
  }
  if (parsed.targetOverride || isAutoExcluded(key, 'targetOverride')) {
    specialTag('targetOverride', '대상', parsed.targetOverride, parsed.targetOverride || '-');
  }
  if (parsed.criticalFloor !== null || manual.criticalFloor !== null || isAutoExcluded(key, 'criticalFloor')) {
    var floorVals = [7];
    if (parsed.criticalFloor !== null) floorVals.push(parsed.criticalFloor);
    if (manual.criticalFloor !== null) floorVals.push(manual.criticalFloor);
    var floor = Math.min.apply(null, floorVals);
    specialTag('criticalFloor', '크리 하한', parsed.criticalFloor !== null || isAutoExcluded(key, 'criticalFloor'), floor);
  }
  var specialFlags = [
    ['dicePenaltyImmune', '다이스 감소 무효'],
    ['initiativeFixedZero', '행동치 0 고정'],
    ['ignoreTargetArmor', '대상 장갑 무시'],
    ['reactionForbidden', '대상 리액션 불가'],
    ['dodgeForbidden', '대상 닷지 불가'],
    ['dodgeNoDiceRoll', '대상 닷지 다이스 롤 불가'],
    ['guardForbidden', '대상 가드 불가'],
    ['coverForbidden', '커버링 불가'],
    ['coverAsNoGuard', '커버링 가드 미적용'],
    ['clearFlight', '비행상태 해제'],
    ['sameEngageForbidden', '같은 인게이지 불가'],
    ['sameEngageAllowed', '같은 인게이지 가능']
  ];
  specialFlags.forEach(function(pair) {
    var field = pair[0], label = pair[1];
    if (field === 'dodgeNoDiceRoll' && parsed.dodgeForbidden && !isAutoExcluded(key, 'dodgeForbidden')) return;
    if (parsed[field] || isAutoExcluded(key, field)) {
      specialTag(field, label, parsed[field]);
    }
  });
  var manualTags = manual.tags || {};
  Object.keys(manualTags).forEach(function(tagKey) {
    var opt = getManualTagOption(tagKey);
    if (!opt) return;
    hasAuto = true;
    var val = manualTags[tagKey];
    var text = (val === true) ? '<span>' + esc(opt.label) + '</span>' : esc(opt.label) + ': <span>' + esc(String(val)) + '</span>';
    specialTags.push('<div class="ttag ttag-special ttag-manual">' + text + '</div>');
  });
  var state = ensureCustomState(key);
  (state.manual || []).forEach(function(rule) {
    if (normalizeBonusFieldKey((rule && rule.field) || '') !== MANUAL_TAG_CUSTOM) return;
    var customText = String((rule && rule.expr) || '').trim();
    if (!customText) return;
    hasAuto = true;
    specialTags.push('<div class="ttag ttag-special ttag-manual"><span>' + esc(customText) + '</span></div>');
  });
  if (specialTags.length) rows.push('<div class="ef-special-tags">' + specialTags.join('') + '</div>');
  if (!hasAuto) rows.push('<div class="ef-preview-empty">자동 인식되지 않음</div>');

  box.innerHTML = renderPreviewBox(key, rows.join(''));
}

function refreshAllEffectPreviews() {
  var nodes = document.querySelectorAll('.ef-dtl.open .ef-preview[id^="ef-preview-"]');
  nodes.forEach(function(node) {
    var key = node.id.replace('ef-preview-', '');
    if (key) renderEffectPreview(key);
  });
}

function onEqChk(chk) {
  var ceqIdx = chk.getAttribute('data-ceq-idx');
  if (ceqIdx !== null && ceqIdx !== '') {
    var type = chk.getAttribute('data-type');
    var idx = parseInt(ceqIdx, 10);
    if (CUSTOM_EQUIP[type] && CUSTOM_EQUIP[type][idx]) {
      CUSTOM_EQUIP[type][idx].checked = !!chk.checked;
      saveCurrentStateDebounced();
      recalc();
    }
    return;
  }
  var type = chk.getAttribute('data-type');
  var key = chk.getAttribute('data-key') || '';
  if (!EQSEL[type] || typeof EQSEL[type] !== 'object' || Array.isArray(EQSEL[type])) EQSEL[type] = {};
  if (key) EQSEL[type][key] = !!chk.checked;
  saveCurrentStateDebounced();
  recalc();
}

function rerenderEquipPanels() {
  if (!G) return;
  var wpEl = document.getElementById('tp-wp');
  if (wpEl) wpEl.innerHTML = buildWeapTable(G.weapons) + buildArmorTable(G.armors);
  var vhEl = document.getElementById('tp-vh');
  if (vhEl) vhEl.innerHTML = buildVehTable(G.vehicles);
  recalc();
}
var CEQ_DEL_SVG = '<svg viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
var CEQ_ADD_SVG = '<svg viewBox="0 0 24 24"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>';

function buildCeqRow(type, item, idx, fields) {
  var h = '<div class="ceq-row" data-ceq-type="' + type + '" data-ceq-idx="' + idx + '">';
  h += '<input type="checkbox" class="eq-chk ceq-chk" data-type="' + type + '" data-ceq-idx="' + idx + '"' + (item.checked !== false ? ' checked' : '') + '>';
  fields.forEach(function(f) {
    var cls = 'ceq-input';
    if (f.cls) cls += ' ' + f.cls;
    var val = item[f.key] != null ? item[f.key] : (f.def || '');
    h += '<input type="' + (f.type || 'text') + '" class="' + cls + '" data-ceq-field="' + f.key + '" value="' + esc(String(val)) + '" placeholder="' + esc(f.ph || '') + '">';
  });
  h += '<button class="ceq-del" data-ceq-type="' + type + '" data-ceq-idx="' + idx + '" title="삭제">' + CEQ_DEL_SVG + '</button>';
  h += '</div>';
  return h;
}

function buildCeqSection(type, fields) {
  var items = CUSTOM_EQUIP[type] || [];
  var h = '';
  items.forEach(function(item, idx) {
    h += buildCeqRow(type, item, idx, fields);
  });
  h += '<div class="ceq-add-wrap"><button class="ceq-add" data-ceq-type="' + type + '">' + CEQ_ADD_SVG + ' 추가</button></div>';
  return h;
}

var CEQ_WEAPON_FIELDS = [
  { key:'name', ph:'무기명', cls:'ceq-name' },
  { key:'type', ph:'종별' },
  { key:'skill', ph:'기능' },
  { key:'range', ph:'사거리' },
  { key:'hit', ph:'명중', cls:'ceq-num', type:'number', def:0 },
  { key:'attack', ph:'공격력', cls:'ceq-num', type:'number', def:0 },
  { key:'guard', ph:'가드치', cls:'ceq-num', type:'number', def:0 }
];
var CEQ_ARMOR_FIELDS = [
  { key:'name', ph:'방어구명', cls:'ceq-name' },
  { key:'type', ph:'종별' },
  { key:'dodge', ph:'닷지' },
  { key:'initiative', ph:'행동', cls:'ceq-num', type:'number', def:0 },
  { key:'armor', ph:'장갑', cls:'ceq-num', type:'number', def:0 }
];
var CEQ_VEHICLE_FIELDS = [
  { key:'name', ph:'비클명', cls:'ceq-name' },
  { key:'type', ph:'종별' },
  { key:'skill', ph:'기능' },
  { key:'attack', ph:'공격력', cls:'ceq-num', type:'number', def:0 },
  { key:'initiative', ph:'행동', cls:'ceq-num', type:'number', def:0 },
  { key:'armor', ph:'장갑', cls:'ceq-num', type:'number', def:0 },
  { key:'move', ph:'이동' }
];

function buildWeapTable(ws) {
  var h = '';
  if (ws && ws.length) {
    var cols = ['\uc120\ud0dd','\ubb34\uae30\uba85','\uc885\ubcc4','\uae30\ub2a5','\uc0ac\uac70\ub9ac','\uba85\uc911','\uacf5\uaca9\ub825','\uac00\ub4dc\uce58'];
    h += tblLbl('\ubb34\uae30') + buildTbl(cols, ws, function(w, idx){
      return [eqChkHtml('weapon', w, idx), esc(String(w['\ubb34\uae30\uba85']||'')), esc(String(w['\uc885\ubcc4']||'')), esc(String(w['\uae30\ub2a5']||'')),
        esc(String(w['\uc0ac\uac70\ub9ac']||'')), esc(String(w['\uba85\uc911']||'')), esc(String(w['\uacf5\uaca9\ub825']||'')), esc(String(w['\uac00\ub4dc\uce58']||''))];
    }, function(w){ return w['\ud574\uc124'] ? '<tr><td colspan="8" style="color:#8a8a8a;font-size:9px">'+esc(String(w['\ud574\uc124']).slice(0,100))+'</td></tr>' : ''; });
  } else {
    h += '<div class="itbl-none">\ubb34\uae30 \uc5c6\uc74c</div>';
  }
  h += buildCeqSection('weapon', CEQ_WEAPON_FIELDS);
  return h;
}
function buildArmorTable(ws) {
  var h = '';
  if (ws && ws.length) {
    var cols = ['\uc120\ud0dd','\ubc29\uc5b4\uad6c\uba85','\uc885\ubcc4','\ub2f7\uc9c0','\ud589\ub3d9','\uc7a5\uac11'];
    h += tblLbl('\ubc29\uc5b4\uad6c') + buildTbl(cols, ws, function(w, idx){
      return [eqChkHtml('armor', w, idx), esc(String(w['\ubc29\uc5b4\uad6c\uba85']||'')), esc(String(w['\uc885\ubcc4']||'')),
        esc(String(w['\ub2f7\uc9c0']||'')), esc(String(w['\ud589\ub3d9']||'')), esc(String(w['\uc7a5\uac11']||''))];
    }, function(){ return ''; });
  } else {
    h += '<div class="itbl-none" style="margin-top:4px">\ubc29\uc5b4\uad6c \uc5c6\uc74c</div>';
  }
  h += buildCeqSection('armor', CEQ_ARMOR_FIELDS);
  return h;
}
function buildVehTable(vs) {
  var h = '';
  if (vs && vs.length) {
    var cols = ['\uc120\ud0dd','\ube44\ud074\uba85','\uc885\ubcc4','\uae30\ub2a5','\uacf5\uaca9\ub825','\ud589\ub3d9','\uc7a5\uac11','\uc774\ub3d9'];
    h += buildTbl(cols, vs, function(v, idx){
      return [eqChkHtml('vehicle', v, idx), esc(String(v['\ube44\ud074\uba85']||'')), esc(String(v['\uc885\ubcc4']||'')), esc(String(v['\uae30\ub2a5']||'')),
        esc(String(v['\uacf5\uaca9\ub825']||'')), esc(String(v['\ud589\ub3d9']||'')), esc(String(v['\uc7a5\uac11']||'')), esc(String(v['\uc774\ub3d9']||''))];
    }, function(){ return ''; });
  } else {
    h += '<div class="itbl-none">\ube44\ud074 \uc5c6\uc74c</div>';
  }
  h += buildCeqSection('vehicle', CEQ_VEHICLE_FIELDS);
  return h;
}
function tblLbl(t) { return '<div style="font-size:9px;color:#8a8a8a;padding:4px 0 2px">' + t + '</div>'; }
function buildTbl(cols, rows, cellsFn, extraFn) {
  var h = '<div class="itbl-wrap"><table class="itbl"><thead><tr>';
  cols.forEach(function(c){ h += '<th>' + c + '</th>'; });
  h += '</tr></thead><tbody>';
  rows.forEach(function(row, idx){
    var cells = cellsFn(row, idx);
    h += '<tr>';
    cells.forEach(function(cell){ h += '<td>' + cell + '</td>'; });
    h += '</tr>' + extraFn(row, idx);
  });
  return h + '</tbody></table></div>';
}

function attachEvents() {
  var erEl = document.getElementById('inp-er');
  if (erEl) { erEl.addEventListener('input', onErInput); erEl.addEventListener('change', onErInput); }
  ['sel-feat','sel-statmod'].forEach(function(id) {
    var el = document.getElementById(id);
    if (el) el.addEventListener('change', function() {
      saveCurrentStateDebounced();
      recalc();
    });
  });
  ['sel-range','sel-target'].forEach(function(id) {
    var el = document.getElementById(id);
    if (!el) return;
    el.addEventListener('input', function() {
      saveCurrentStateDebounced();
      recalcDebounced();
    });
    el.addEventListener('change', function() {
      saveCurrentStateDebounced();
      recalc();
    });
  });
  if (!window.__dx3ComboOutsideBound) {
    document.addEventListener('click', function(e) {
      if (!e.target.closest('.ccombo')) closeCustomCombos();
    });
    window.__dx3ComboOutsideBound = true;
  }
  var masterTabs = document.getElementById('pc-master-tabs');
  if (masterTabs && !masterTabs.__dx3Bound) {
    masterTabs.__dx3Bound = true;
    masterTabs.addEventListener('click', function(e) {
      var tab = e.target.closest('.pc-tab');
      if (!tab || !MASTER_MODE) return;
      if (tab.disabled || tab.hasAttribute('disabled')) return;
      var sn = tab.getAttribute('data-sheet') || '';
      if (!sn) return;
      activateMasterSheet(sn);
    });
  }

  [['tab-ef','tp-ef'],['tab-wp','tp-wp'],['tab-vh','tp-vh']].forEach(function(pair) {
    var btn = document.getElementById(pair[0]);
    if (btn) btn.addEventListener('click', function() {
      document.querySelectorAll('.tab').forEach(function(b){ b.classList.remove('on'); });
      document.querySelectorAll('.tp').forEach(function(p){ p.classList.remove('on'); });
      btn.classList.add('on');
      document.getElementById(pair[1]).classList.add('on');
    });
  });

  var lc = document.getElementById('left-content');
  if (lc && !lc.__dx3Bound) {
    lc.__dx3Bound = true;
    lc.addEventListener('click', function(e) {
    var statAllBtn = e.target.closest('#btn-stat-all');
    var comboBtn = e.target.closest('.ccombo-btn');
    var comboOpt = e.target.closest('.ccombo-opt');
    var eqChk = e.target.closest('.eq-chk');
    var chk = e.target.closest('.ef-chk');
    var hd  = e.target.closest('.ef-hd');
    var manAddBtn = e.target.closest('.ef-man-add');
    var manTglBtn = e.target.closest('.ef-man-tgl');
    var condHideBtn = e.target.closest('.ttag-cond-hide');
    var prevTglBtn = e.target.closest('.ef-prev-tgl');
    var prevRefreshBtn = e.target.closest('.ef-prev-refresh');
    var efManualDel = e.target.closest('.ef-man-del');
    var lvExBtn = e.target.closest('.ef-lvex');
    var extraAdjSectionHide = e.target.closest('#btn-extra-adj-section-hide');
    var extraAdjGroupAdd = e.target.closest('#btn-extra-adj-group-add');
    var extraAdjGroupDel = e.target.closest('.extra-adj-group-del');
    var extraAdjRowAdd = e.target.closest('.extra-adj-row-add');
    var extraAdjHide = e.target.closest('.extra-adj-hide');
    var extraAdjDel = e.target.closest('.extra-adj-del');
    var efAutoCutBtn = e.target.closest('.ef-auto-cut') || e.target.closest('.ttag-special-hide');
    var mtagAddBtn = e.target.closest('.ef-mtag-add');
    var mtagDelBtn = e.target.closest('.ef-mtag-del');
    var efTextResetBtn = e.target.closest('.ef-text-reset');
    var ceqAddBtn = e.target.closest('.ceq-add');
    var ceqDelBtn = e.target.closest('.ceq-del');
    var exportStateBtn = e.target.closest('#btn-export-state');
    var importStateBtn = e.target.closest('#btn-import-state');
    if (statAllBtn) {
      e.preventDefault();
      var statWrap = document.getElementById('stat-wrap');
      if (!statWrap) return;
      statWrap.classList.toggle('collapsed');
      statAllBtn.classList.toggle('open', !statWrap.classList.contains('collapsed'));
    } else if (comboBtn) {
      e.preventDefault();
      var combo = comboBtn.closest('.ccombo');
      if (!combo) return;
      var willOpen = !combo.classList.contains('open');
      closeCustomCombos();
      if (willOpen) combo.classList.add('open');
    } else if (comboOpt) {
      e.preventDefault();
      var parentCombo = comboOpt.closest('.ccombo');
      if (!parentCombo) return;
      var inputId = parentCombo.getAttribute('data-input');
      var value = comboOpt.getAttribute('data-value') || '';
      if (setCustomComboValue(inputId, value)) {
        recalc();
      }
      closeCustomCombos();
    } else if (ceqAddBtn) {
      e.preventDefault();
      var ceqType = ceqAddBtn.getAttribute('data-ceq-type');
      if (ceqType && CUSTOM_EQUIP[ceqType]) {
        var blank = { checked: true };
        var fields = ceqType === 'weapon' ? CEQ_WEAPON_FIELDS : ceqType === 'armor' ? CEQ_ARMOR_FIELDS : CEQ_VEHICLE_FIELDS;
        fields.forEach(function(f) { blank[f.key] = f.def != null ? f.def : ''; });
        CUSTOM_EQUIP[ceqType].push(blank);
        saveCurrentState();
        rerenderEquipPanels();
      }
    } else if (ceqDelBtn) {
      e.preventDefault();
      var ceqType = ceqDelBtn.getAttribute('data-ceq-type');
      var ceqIdx = parseInt(ceqDelBtn.getAttribute('data-ceq-idx'), 10);
      if (ceqType && CUSTOM_EQUIP[ceqType] && !isNaN(ceqIdx)) {
        CUSTOM_EQUIP[ceqType].splice(ceqIdx, 1);
        saveCurrentState();
        rerenderEquipPanels();
      }
    } else if (eqChk) {
      onEqChk(eqChk);
    } else if (mtagAddBtn) {
      e.preventDefault();
      e.stopPropagation();
      var mtagAddKey = mtagAddBtn.getAttribute('data-key') || '';
      if (!mtagAddKey) return;
      var mtagRow = mtagAddBtn.closest('.ef-mtag-add-row');
      var mtagValue = mtagRow ? mtagRow.querySelector('.ef-mtag-add-value') : null;
      var ok = addManualTag(mtagAddKey, mtagValue ? mtagValue.value : '', '');
      if (!ok && mtagValue) mtagValue.focus();
    } else if (mtagDelBtn) {
      e.preventDefault();
      e.stopPropagation();
      var mtagDelKey = mtagDelBtn.getAttribute('data-key') || '';
      var mtagDelIdx = parseInt(mtagDelBtn.getAttribute('data-tidx'), 10);
      if (!mtagDelKey || isNaN(mtagDelIdx)) return;
      var mtagDelSt = ensureCustomState(mtagDelKey);
      if (mtagDelSt.manualTags[mtagDelIdx] != null) mtagDelSt.manualTags.splice(mtagDelIdx, 1);
      saveCurrentStateDebounced();
      clearEffectParseCache();
      renderManualEditor(mtagDelKey);
      recalc();
    } else if (manAddBtn) {
      e.preventDefault();
      e.stopPropagation();
      var key = manAddBtn.getAttribute('data-key') || '';
      if (!key) return;
      addManualRule(key);
      recalc();
    } else if (manTglBtn) {
      e.preventDefault();
      e.stopPropagation();
      var tglKey = manTglBtn.getAttribute('data-key') || '';
      if (!tglKey) return;
      toggleManualEditor(tglKey);
    } else if (condHideBtn) {
      e.preventDefault();
      e.stopPropagation();
      var hideKey = condHideBtn.getAttribute('data-key') || '';
      var hideCond = condHideBtn.getAttribute('data-cond') || '';
      if (!hideKey || !hideCond) return;
      toggleCondTagHidden(hideKey, hideCond);
      markPreviewDirty(hideKey);
    } else if (prevTglBtn) {
      e.preventDefault();
      e.stopPropagation();
      var prevKey = prevTglBtn.getAttribute('data-key') || '';
      if (!prevKey) return;
      togglePreviewBox(prevKey);
    } else if (prevRefreshBtn) {
      e.preventDefault();
      e.stopPropagation();
      var refKey = prevRefreshBtn.getAttribute('data-key') || '';
      if (!refKey) return;
      resetAutoPreviewFilters(refKey);
      recalc();
    } else if (efManualDel) {
      e.preventDefault();
      e.stopPropagation();
      var delKey = efManualDel.getAttribute('data-key') || '';
      var delRow = +efManualDel.getAttribute('data-row');
      removeManualRule(delKey, delRow);
      recalc();
    } else if (lvExBtn) {
      e.preventDefault();
      e.stopPropagation();
      var lvExKey = lvExBtn.getAttribute('data-key') || '';
      if (!lvExKey) return;
      toggleLvBonusException(lvExKey);
      renderEffectTags(lvExKey);
      recalc();
    } else if (extraAdjSectionHide) {
      e.preventDefault();
      e.stopPropagation();
      toggleExtraAdjSectionHidden();
    } else if (extraAdjGroupAdd) {
      e.preventDefault();
      e.stopPropagation();
      addExtraAdjGroup();
      recalc();
    } else if (extraAdjGroupDel) {
      e.preventDefault();
      e.stopPropagation();
      removeExtraAdjGroup(+extraAdjGroupDel.getAttribute('data-group'));
      recalc();
    } else if (extraAdjRowAdd) {
      e.preventDefault();
      e.stopPropagation();
      addExtraAdjRow(+extraAdjRowAdd.getAttribute('data-group'));
      recalc();
    } else if (extraAdjHide) {
      e.preventDefault();
      e.stopPropagation();
      toggleExtraAdjGroupHidden(+extraAdjHide.getAttribute('data-group'));
    } else if (extraAdjDel) {
      e.preventDefault();
      e.stopPropagation();
      removeExtraAdjRow(+extraAdjDel.getAttribute('data-group'), +extraAdjDel.getAttribute('data-row'));
      recalc();
    } else if (efAutoCutBtn) {
      e.preventDefault();
      e.stopPropagation();
      var cutKey = efAutoCutBtn.getAttribute('data-key') || '';
      var cutField = efAutoCutBtn.getAttribute('data-field') || '';
      if (!cutKey || !cutField) return;
      toggleAutoExcludeField(cutKey, cutField);
      if (cutField === 'rangeOverride' || cutField === 'targetOverride') autoParamFromSelectedEffects();
      recalc();
    } else if (efTextResetBtn) {
      e.preventDefault();
      e.stopPropagation();
      var textResetKey = efTextResetBtn.getAttribute('data-key') || '';
      if (!textResetKey) return;
      resetEffectText(textResetKey);
    } else if (exportStateBtn) {
      e.preventDefault();
      e.stopPropagation();
      saveStateToSheetSlot();
    } else if (importStateBtn) {
      e.preventDefault();
      e.stopPropagation();
      loadStateFromSheetSlot();
    } else if (chk) {
      e.stopPropagation();
      onChk(chk.getAttribute('data-key') || '', chk);
    } else if (hd) {
      var key = hd.id.replace('hd-','');
      var dtl = document.getElementById('dtl-' + key);
      if (dtl) {
        var willOpen = !dtl.classList.contains('open');
        if (willOpen) {
          dtl.classList.add('open');
          dtl.style.maxHeight = dtl.scrollHeight + 'px';
          markPreviewDirty(key);
          setTimeout(function() { if (dtl.classList.contains('open')) dtl.style.maxHeight = ''; }, 300);
        } else {
          dtl.style.maxHeight = dtl.scrollHeight + 'px';
          dtl.offsetHeight; // force reflow
          dtl.style.maxHeight = '0';
          dtl.classList.remove('open');
        }
      }
    }
    });
    lc.addEventListener('change', function(e) {
      if (handleCalcOptionToggle(e.target)) return;
      var ceqInput = e.target.closest('.ceq-input');
      if (ceqInput) {
        var row = ceqInput.closest('.ceq-row');
        if (row) {
          var ct = row.getAttribute('data-ceq-type');
          var ci = parseInt(row.getAttribute('data-ceq-idx'), 10);
          var field = ceqInput.getAttribute('data-ceq-field');
          if (ct && CUSTOM_EQUIP[ct] && CUSTOM_EQUIP[ct][ci] && field) {
            var v = ceqInput.value;
            CUSTOM_EQUIP[ct][ci][field] = ceqInput.type === 'number' ? (+v || 0) : v;
            saveCurrentStateDebounced();
            recalc();
          }
        }
        return;
      }
      var fld = e.target.closest('.ef-man-field');
      if (fld) {
        updateManualRuleGroupField(fld.getAttribute('data-key') || '', +fld.getAttribute('data-row'), fld.value || 'dice');
        recalc();
        return;
      }
      var mtagFld = e.target.closest('.ef-mtag-add-field');
      if (mtagFld) {
        syncManualTagAddRowUi(mtagFld.closest('.ef-mtag-add-row'));
        return;
      }
      var adjChk = e.target.closest('.extra-adj-chk');
      if (adjChk) {
        setExtraAdjGroupEnabled(+adjChk.getAttribute('data-group'), !!adjChk.checked);
        recalc();
        return;
      }
      var adjName = e.target.closest('.extra-adj-name');
      if (adjName) {
        updateExtraAdjGroupName(+adjName.getAttribute('data-group'), adjName.value || '');
        return;
      }
      var adjFld = e.target.closest('.extra-adj-field');
      if (adjFld) {
        updateExtraAdjRowField(+adjFld.getAttribute('data-group'), +adjFld.getAttribute('data-row'), adjFld.value || 'achievement');
        recalc();
        return;
      }
    });
    lc.addEventListener('input', function(e) {
      var effectTextInput = e.target.closest('.ef-textarea');
      if (effectTextInput) {
        applyEffectTextChange(effectTextInput.getAttribute('data-key') || '', effectTextInput.value || '');
        return;
      }
      var ceqInput = e.target.closest('.ceq-input');
      if (ceqInput) {
        var row = ceqInput.closest('.ceq-row');
        if (row) {
          var ct = row.getAttribute('data-ceq-type');
          var ci = parseInt(row.getAttribute('data-ceq-idx'), 10);
          var field = ceqInput.getAttribute('data-ceq-field');
          if (ct && CUSTOM_EQUIP[ct] && CUSTOM_EQUIP[ct][ci] && field) {
            var v = ceqInput.value;
            CUSTOM_EQUIP[ct][ci][field] = ceqInput.type === 'number' ? (+v || 0) : v;
            saveCurrentStateDebounced();
            recalcDebounced();
          }
        }
        return;
      }
      var expr = e.target.closest('.ef-man-expr');
      if (expr) {
        updateManualRuleExpr(expr.getAttribute('data-key') || '', +expr.getAttribute('data-row'), expr.value || '');
        recalcDebounced();
        return;
      }
      var adjNameInput = e.target.closest('.extra-adj-name');
      if (adjNameInput) {
        updateExtraAdjGroupName(+adjNameInput.getAttribute('data-group'), adjNameInput.value || '');
        return;
      }
      var adjVal = e.target.closest('.extra-adj-val');
      if (adjVal) {
        updateExtraAdjRowValue(+adjVal.getAttribute('data-group'), +adjVal.getAttribute('data-row'), adjVal.value || '');
        recalcDebounced();
      }
    });
  }
  syncManualTagAddRowUi(document.getElementById('left-content') || document);
}

function onErInput() {
  var er = +document.getElementById('inp-er').value || 0;
  var st = erStage(er);
  var stage = document.getElementById('er-stage');
  if (stage) { stage.textContent = st.label; stage.style.color = st.color; }
  var badges = document.getElementById('er-badges');
  if (badges && G) badges.innerHTML = buildBadges(er, G);
  saveCurrentStateDebounced();
  recalcDebounced();
}

function onChk(key, chk) {
  var ef = getEffectByKey(key);
  if (!ef) return;
  var card   = document.getElementById('ei-' + key);
  if (chk.checked) {
    var exists = false;
    SEL.forEach(function(s){ if (s.key === key) exists = true; });
    if (!exists) SEL.push({ key: key, ef: ef });
    if (card) card.classList.add('on');
  } else {
    SEL = SEL.filter(function(s){ return s.key !== key; });
    if (card) card.classList.remove('on');
  }
  autoParamFromSelectedEffects();
  saveCurrentStateDebounced();
  recalc();
}

function includesValue(list, value) {
  return list.indexOf(value) >= 0;
}

function parseTargetAuto(raw) {
  var t = String(raw || '').trim();
  if (!t || t === '-') return null;
  if (t.indexOf('\uc528') >= 0) return { rank: 4, value: '\uc528' };
  if (t.indexOf('\ubc94\uc704') >= 0) {
    if (t.indexOf('\uc804\uccb4') >= 0) return { rank: 3, value: '\ubc94\uc704(\uc804\uccb4)' };
    return { rank: 3, value: '\ubc94\uc704(\uc120\ud0dd)' };
  }
  if (t.indexOf('\ub2e8\uc77c') >= 0) return { rank: 2, value: '\ub2e8\uc77c' };
  if (t.indexOf('2\uccb4') >= 0) return { rank: 2, value: '2\uccb4' };
  if (t.indexOf('1\uccb4') >= 0) return { rank: 2, value: '1\uccb4' };
  if (t.indexOf('\uc790\uc2e0') >= 0) return { rank: 1, value: '\uc790\uc2e0' };
  return null;
}

function parseRangeAuto(raw) {
  var r = String(raw || '').trim();
  if (!r || r === '-') return null;
  if (r.indexOf('\ubb34\uae30') >= 0) return { rank: 3, value: '\ubb34\uae30' };
  if (r.indexOf('\uadfc\uc811') >= 0 || r.indexOf('\uc9c0\uadfc') >= 0) return { rank: 2, value: '\uadfc\uc811' };
  if (r.indexOf('\uc2dc\uc57c') >= 0) return { rank: 1, value: '\uc2dc\uc57c' };
  if (r.indexOf('5m') >= 0) return { rank: 0, value: '5m' };
  if (r.indexOf('\uc528') >= 0) return { rank: -1, value: '\uc528' };
  return null;
}

function parseTargetTagFromText(raw) {
  var src = String(raw || '');
  if (!src) return null;
  var m = /대상\s*[::]\s*([^\]】」\n\r]+)/i.exec(src);
  if (!m || !m[1]) return null;
  return parseTargetAuto(m[1]);
}

function parseTargetChangeFromText(raw) {
  var src = String(raw || '');
  if (!src) return null;
  var m = /대상[^\n\r]{0,30}?(자신|단일|1체|2체|범위\s*\(선택\)|범위\s*\(전체\)|씬)\s*로\s*변경/i.exec(src);
  if (!m || !m[1]) return null;
  return parseTargetAuto(m[1]);
}

function parseRangeTagFromText(raw) {
  var src = String(raw || '');
  if (!src) return null;
  var m = /사정(?:거리)?\s*[::]\s*(무기|근접|지근|시야|5m|씬)/i.exec(src);
  if (!m || !m[1]) return null;
  return parseRangeAuto(m[1]);
}

function parseRangeChangeFromText(raw) {
  var src = String(raw || '');
  if (!src) return null;
  var m = /사정(?:거리)?[^\n\r]{0,24}?(무기|근접|지근|시야|5m|씬)\s*로\s*변경/i.exec(src);
  if (!m || !m[1]) return null;
  return parseRangeAuto(m[1]);
}

function autoParamFromSelectedEffects() {
  if (!SEL || !SEL.length) return;
  var bestTarget = null;
  var bestRange = null;
  var forceTarget = null;
  var forceRange = null;

  SEL.forEach(function(s) {
    var ef = s.ef || {};
    var t = parseTargetAuto(ef.target);
    if (!t) t = parseTargetTagFromText(ef.effect);
    if (!t) t = parseTargetChangeFromText(ef.effect);
    if (t && (!bestTarget || t.rank > bestTarget.rank)) bestTarget = t;
    if (!isAutoExcluded(s.key, 'targetOverride')) {
      var tText = parseTargetChangeFromText(ef.effect) || parseTargetTagFromText(ef.effect);
      if (tText && (!forceTarget || tText.rank > forceTarget.rank)) forceTarget = tText;
    }
    var st = ensureCustomState(s.key);
    if (st.manualTags && st.manualTags.length) {
      st.manualTags.forEach(function(mt) {
        if (mt.tag === 'targetOverride' && mt.value) {
          var mtTarget = parseTargetAuto(mt.value);
          if (mtTarget && (!forceTarget || mtTarget.rank > forceTarget.rank)) forceTarget = mtTarget;
        }
        if (mt.tag === 'rangeOverride' && mt.value) {
          var mtRange = parseRangeAuto(mt.value);
          if (mtRange && (!forceRange || mtRange.rank > forceRange.rank)) forceRange = mtRange;
        }
      });
    }
    var r = parseRangeAuto(ef.range);
    if (!r) r = parseRangeTagFromText(ef.effect);
    if (!r) r = parseRangeChangeFromText(ef.effect);
    if (r && (!bestRange || r.rank > bestRange.rank)) bestRange = r;
    if (!isAutoExcluded(s.key, 'rangeOverride')) {
      var rText = parseRangeTagFromText(ef.effect) || parseRangeChangeFromText(ef.effect);
      if (rText && (!forceRange || rText.rank > forceRange.rank)) forceRange = rText;
    }
  });

  var finalTarget = forceTarget || bestTarget;
  var finalRange = forceRange || bestRange;
  if (finalTarget && includesValue(TARGET_OPTIONS, finalTarget.value)) {
    setCustomComboValue('sel-target', finalTarget.value);
  }
  if (finalRange && includesValue(RANGE_OPTIONS, finalRange.value)) {
    setCustomComboValue('sel-range', finalRange.value);
  }
}

function toNum(v) {
  var n = Number(v);
  return isNaN(n) ? 0 : n;
}

function normalizeEffectText(text) {
  return String(text || '')
    .replace(/[−–—‑]/g, '-')
    .replace(/[+]/g, '+')
    .replace(/\u00A0/g, ' ')
    .replace(/\s+/g, ' ')
    .trim();
}

function fmtSigned(v) {
  if (v > 0) return '+' + v;
  if (v < 0) return String(v);
  return '0';
}

function fmtDiceSigned(v) {
  if (v > 0) return '+' + v + 'D';
  if (v < 0) return String(v) + 'D';
  return '0D';
}

function fmtStatWithDice(flat, dice) {
  if (flat === 0 && dice === 0) return '0';
  var parts = [];
  if (flat !== 0) parts.push(String(flat));
  if (dice > 0) parts.push((flat !== 0 ? '+' : '') + dice + 'D');
  else if (dice < 0) parts.push(dice + 'D');
  if (parts.length === 0) return '0';
  return parts.join('');
}

function fmtStatWithDiceSigned(flat, dice) {
  if (flat === 0 && dice === 0) return '+0';
  var s = '';
  if (flat !== 0) s += (flat > 0 ? '+' : '') + flat;
  if (dice > 0) s += '+' + dice + 'D';
  else if (dice < 0) s += dice + 'D';
  if (!s) return '+0';
  if (s.charAt(0) !== '+' && s.charAt(0) !== '-') s = '+' + s;
  return s;
}

function evalLvExpr(expr, lv) {
  var e = String(expr || '');
  if (!e) return NaN;
  e = e.replace(/(\d)\s*LV/g, '$1*LV');
  e = e.replace(/LV\s*(\d)/g, 'LV*$1');
  e = e.replace(/\)\s*LV/g, ')*LV');
  e = e.replace(/LV\s*\(/g, 'LV*(');
  e = e.replace(/(\d)\s*\(/g, '$1*(');
  e = e.replace(/\)\s*(\d)/g, ')*$1');
  e = e.replace(/LV/g, String(lv));
  if (!/^[0-9+\-*/().]+$/.test(e)) return NaN;
  try {
    var out = Number(new Function('return (' + e + ');')());
    return isFinite(out) ? out : NaN;
  } catch (_) {
    return NaN;
  }
}

var VALUE_TOKEN_PATTERN = '(?:\\[[^\\]]+\\]|【[^】]+】|\\([^)]*\\)|[A-Za-z0-9+\\-*\\/\\.\\u00D7xX]+)';
var STAT_VALUE_TOKEN_PATTERN = '(?:\\[[^\\]]+\\]|【[^】]+】|\\([^)]*\\)|[A-Za-z0-9+\\-*\\/\\.\\u00D7xX]+|[\\uAC00-\\uD7A3]{1,12})';
var STAT_VALUE_TOKEN_WITH_D = '(?:\\[[^\\]]+\\]\\s*[dD]?(?:10)?|【[^】]+】\\s*[dD]?(?:10)?|\\([^)]*\\)\\s*[dD]?(?:10)?|[A-Za-z0-9+\\-*\\/\\.\\u00D7xX]+|[\\uAC00-\\uD7A3]{1,12})';

function tokenHasDiceSuffix(rawToken) {
  var t = normalizeTokenKey(rawToken);
  t = t.replace(/\u00D7/g, '*');
  return /D(?:10)?$/i.test(t);
}

function normalizeTokenKey(token) {
  return String(token || '')
    .replace(/[【】\[\]\(\)〈〉《》<>「」『』]/g, '')
    .replace(/\s+/g, '')
    .toUpperCase();
}

function getCurrentErosionValue() {
  var input = document.getElementById('inp-er');
  if (input && input.value !== '') {
    var current = Number(input.value);
    if (!isNaN(current)) return current;
  }
  var base = Number((G || {}).erosion);
  return isNaN(base) ? 0 : base;
}

function stripKnownTokenAnnotations(token) {
  return String(token || '')
    .replace(/[【】\[\]〈〉《》<>「」『』]/g, '')
    .replace(/\((?:최대|최저|최소)\s*[+\-]?\d+[^)]*\)/gi, '')
    .replace(/\((?:끝수\s*버림|끝수버림|1의\s*자리\s*버림|1의자리버림)\)/gi, '')
    .replace(/(?:끝수\s*버림|끝수버림|1의\s*자리\s*버림|1의자리버림)/gi, '')
    .replace(/\u00A0/g, ' ')
    .replace(/[−–—‑]/g, '-')
    .replace(/[+]/g, '+')
    .replace(/[÷/]/g, '/')
    .replace(/[×xX]/g, '*')
    .replace(/\s+/g, '');
}

function getInlineConstraintMeta(token) {
  var src = String(token || '');
  var max = null, min = null;
  var maxMatch = /최대\s*([+\-]?\d+)/i.exec(src);
  if (maxMatch) {
    max = parseInt(maxMatch[1], 10);
    if (isNaN(max)) max = null;
  }
  var minMatch = /(?:최저|최소)\s*([+\-]?\d+)/i.exec(src);
  if (minMatch) {
    min = parseInt(minMatch[1], 10);
    if (isNaN(min)) min = null;
  }
  return { max: max, min: min };
}

function applyNumericConstraintMeta(value, meta) {
  var out = Number(value);
  if (isNaN(out)) return NaN;
  if (meta && meta.max !== null && out > meta.max) out = meta.max;
  if (meta && meta.min !== null && out < meta.min) out = meta.min;
  return out;
}

function getTrailingConstraintMeta(rawToken, ctx) {
  var src = String(ctx || '');
  var token = String(rawToken || '');
  if (!src) return { max: null, min: null };
  var start = 0;
  if (token) {
    var idx = src.indexOf(token);
    if (idx >= 0) start = idx + token.length;
  }
  return getInlineConstraintMeta(src.slice(start, start + 24));
}

function applyTrailingConstraintMeta(value, rawToken, ctx) {
  return applyNumericConstraintMeta(value, getTrailingConstraintMeta(rawToken, ctx));
}

function buildTokenValueMap() {
  var map = {};
  var stats = (G && G.statValues) ? G.statValues : {};
  function addAliases(aliases, value) {
    var n = Number(value);
    if (isNaN(n)) return;
    aliases.forEach(function(alias) {
      var k = normalizeTokenKey(alias);
      if (!k) return;
      map[k] = n;
    });
  }
  addAliases(['육체', '신체', '肉体', 'PHYSICAL', 'BODY'], stats.physical);
  addAliases(['감각', '感覚', 'SENSE'], stats.sense);
  addAliases(['정신', '精神', 'MENTAL', 'MIND'], stats.mental);
  addAliases(['사회', '社会', 'SOCIAL'], stats.social);
  addAliases(['행동치', '행동값', '行動値', 'INITIATIVE'], (G || {}).initiative);
  addAliases(['침식률', '현재침식률', '당신의침식률', '자신의침식률', '나의침식률', '이에너미의침식률'], getCurrentErosionValue());

  var skills = (G && G.skills) ? G.skills : {};
  STAT_KEYS.forEach(function(statKey) {
    (skills[statKey] || []).forEach(function(sk) {
      var name = normalizeTokenKey((sk || {}).name || '');
      if (!name) return;
      var v = Number((sk || {}).value);
      if (isNaN(v)) return;
      if (!Object.prototype.hasOwnProperty.call(map, name)) map[name] = v;
      else if (Math.abs(v) > Math.abs(map[name])) map[name] = v;
    });
  });
  return map;
}

function getTokenValueMap() {
  var key = String(CUR_SHEET || '') + '|' + String(CUR_DATA_VERSION || '') + '|ER:' + String(getCurrentErosionValue());
  if (_TOKEN_VALUE_MAP_CACHE && _TOKEN_VALUE_MAP_CACHE_KEY === key) return _TOKEN_VALUE_MAP_CACHE;
  _TOKEN_VALUE_MAP_CACHE = buildTokenValueMap();
  _TOKEN_VALUE_MAP_CACHE_KEY = key;
  return _TOKEN_VALUE_MAP_CACHE;
}

function getTokenNumericValue(token) {
  var k = normalizeTokenKey(token);
  if (!k) return NaN;
  var map = getTokenValueMap();
  if (!Object.prototype.hasOwnProperty.call(map, k)) return NaN;
  var n = Number(map[k]);
  return isNaN(n) ? NaN : n;
}

function parseLvToken(token, lv) {
  var raw = String(token || '');
  if (!raw) return NaN;
  var flags = {
    floorInteger: /끝수\s*버림|끝수버림/i.test(raw),
    dropOnes: /1의\s*자리\s*버림|1의자리버림/i.test(raw)
  };
  var t = stripKnownTokenAnnotations(raw);
  t = t.replace(/D(?:10)?$/i, '');
  if (!t) return NaN;
  var directTokenVal = getTokenNumericValue(t);
  var out = directTokenVal;
  if (!isNaN(out)) {
    out = applyNumericConstraintMeta(out, getInlineConstraintMeta(raw));
    if (flags.dropOnes) return Math.floor(out / 10) * 10;
    if (flags.floorInteger) return Math.floor(out);
    return out;
  }
  t = t.replace(/[A-Z\uAC00-\uD7A3]+/g, function(m) {
    if (m === 'LV') return m;
    var v = getTokenNumericValue(m);
    return isNaN(v) ? m : String(v);
  });
  out = evalLvExpr(t, lv);
  if (isNaN(out)) return NaN;
  out = applyNumericConstraintMeta(out, getInlineConstraintMeta(raw));
  if (flags.dropOnes) return Math.floor(out / 10) * 10;
  if (flags.floorInteger) return Math.floor(out);
  return out;
}

function stripDiceTermsForLvExpr(token) {
  var s = String(token || '');
  if (!s) return '';
  // e.g. (LV)D, (LV+2)D10
  s = s.replace(/\([^)]*\)\s*[dD]\s*\d*/g, '');
  // e.g. LVD, LV D10, 2D, 2D10
  s = s.replace(/(^|[+\-])\s*(?:LV|\d+)\s*[dD]\s*\d*/gi, '$1');
  // Cleanup after term removal
  s = s.replace(/\+\s*\+/g, '+')
       .replace(/-\s*-/g, '+')
       .replace(/\+\s*-/g, '-')
       .replace(/-\s*\+/g, '-')
       .replace(/^[+\s]+/, '')
       .trim();
  return s;
}

function parseLvTokenWithDiceFallback(token, lv) {
  var direct = parseLvToken(token, lv);
  if (!isNaN(direct)) return direct;
  var stripped = stripDiceTermsForLvExpr(token);
  if (!stripped) return NaN;
  return parseLvToken(stripped, lv);
}

function extractDiceCountFromToken(token, lv) {
  var rawSource = String(token || '');
  var src = stripKnownTokenAnnotations(token);
  if (!src) return 0;
  src = src
    .replace(/\u00A0/g, ' ')
    .replace(/[−–—‑]/g, '-')
    .replace(/[+]/g, '+')
    .replace(/[÷/]/g, '/')
    .replace(/[×xX]/g, '*')
    .replace(/\s+/g, '');
  if (!src) return 0;
  var sum = 0;
  var re = /([+\-]?)(?:\(([^)]*)\)|([A-Za-z0-9+\-*]+))D(?:10)?/gi;
  var m;
  while ((m = re.exec(src)) !== null) {
    var sign = (m[1] === '-') ? -1 : 1;
    var expr = m[2] || m[3] || '';
    var count = parseLvToken(expr, lv);
    if (isNaN(count)) continue;
    count = applyNumericConstraintMeta(count, getInlineConstraintMeta(rawSource));
    sum += sign * count;
  }
  return sum;
}

function getStatDeltaRegex(keyword) {
  if (!_RE_STAT_CACHE[keyword]) {
    _RE_STAT_CACHE[keyword] = [
      new RegExp(keyword + '(?!\\s*[::]).{0,24}?([+\\-])\\s*(' + STAT_VALUE_TOKEN_PATTERN + ')', 'gi'),
      new RegExp(keyword + '\\s*[::]\\s*([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_PATTERN + ')', 'gi')
    ];
  }
  _RE_STAT_CACHE[keyword].forEach(function(re) { re.lastIndex = 0; });
  return _RE_STAT_CACHE[keyword];
}

function extractStatDelta(text, keyword, lv) {
  var src = normalizeEffectText(text);
  if (!src || !keyword) return 0;
  var sum = 0;
  var res = getStatDeltaRegex(keyword);
  res.forEach(function(re) {
    re.lastIndex = 0;
    var m;
    while ((m = re.exec(src)) !== null) {
      if (/무시/.test(m[0])) continue;
      var sign = m[1] === '-' ? -1 : 1;
      var ctx = src.slice(m.index, Math.min(src.length, m.index + 80));
      var val = parseLvToken(m[2], lv);
      if (!isNaN(val)) sum += applyTrailingConstraintMeta(sign * val, m[2], ctx);
    }
  });
  return sum;
}

function getStatDeltaSplitRegex(keyword) {
  var cacheKey = keyword + '__split';
  if (!_RE_STAT_CACHE[cacheKey]) {
    _RE_STAT_CACHE[cacheKey] = [
      new RegExp(keyword + '(?!\\s*[::]).{0,24}?([+\\-])\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')', 'gi'),
      new RegExp(keyword + '\\s*[::]\\s*([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')', 'gi')
    ];
  }
  _RE_STAT_CACHE[cacheKey].forEach(function(re) { re.lastIndex = 0; });
  return _RE_STAT_CACHE[cacheKey];
}

function extractStatDeltaSplit(text, keyword, lv) {
  var src = normalizeEffectText(text);
  if (!src || !keyword) return { flat: 0, dice: 0 };
  var flat = 0, dice = 0;
  var res = getStatDeltaSplitRegex(keyword);
  res.forEach(function(re) {
    re.lastIndex = 0;
    var m;
    while ((m = re.exec(src)) !== null) {
      if (/무시/.test(m[0])) continue;
      var sign = m[1] === '-' ? -1 : 1;
      var rawToken = m[2];
      var isDice = tokenHasDiceSuffix(rawToken);
      var ctx = src.slice(m.index, Math.min(src.length, m.index + 80));
      var val = parseLvToken(rawToken, lv);
      if (!isNaN(val)) {
        var delta = applyTrailingConstraintMeta(sign * val, rawToken, ctx);
        if (isDice) dice += delta;
        else flat += delta;
      }
    }
  });
  return { flat: flat, dice: dice };
}

function extractStatDeltaMultiSplit(text, keywords, lv) {
  var flat = 0, dice = 0;
  keywords.forEach(function(kw) {
    var r = extractStatDeltaSplit(text, kw, lv);
    flat += r.flat;
    dice += r.dice;
  });
  return { flat: flat, dice: dice };
}

function extractHitDeltaSplit(text, lv) {
  var src = normalizeEffectText(text);
  if (!src) return { flat: 0, dice: 0 };
  var flat = 0, dice = 0;
  [
    new RegExp('명중\\s*[::]\\s*([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')', 'gi'),
    new RegExp('명중(?:치)?(?!\\s*판정)(?!판정)\\s*(?:을|를|은|는|이|가)?\\s*([+\\-])\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')', 'gi')
  ].forEach(function(re) {
    re.lastIndex = 0;
    var m;
    while ((m = re.exec(src)) !== null) {
      var sign = m[1] === '-' ? -1 : 1;
      var rawToken = m[2];
      var val = parseLvToken(rawToken, lv);
      if (isNaN(val)) continue;
      if (tokenHasDiceSuffix(rawToken)) dice += sign * val;
      else flat += sign * val;
    }
  });
  return { flat: flat, dice: dice };
}

function extractAchievementDeltaSplit(text, lv) {
  return extractStatDeltaSplit(text, '달성치', lv);
}

function getUniqueRegexMatches(src, patterns) {
  var out = [];
  patterns.forEach(function(re) {
    re.lastIndex = 0;
    var m;
    while ((m = re.exec(src)) !== null) {
      out.push(m);
    }
  });
  return out;
}

function hasMaxHpContext(src, idx) {
  return /최대\s*$/.test(src.slice(Math.max(0, idx - 6), idx));
}

function shouldSkipGenericDamageRollMatch(src, idx, matchedText) {
  var end = idx + Math.max(String(matchedText || '').length, 24);
  var ctx = src.slice(Math.max(0, idx - 24), Math.min(src.length, end));
  return /HP\s*대미지/i.test(ctx) || /(받을|받는|입을|예정인|적용될|적용되기|경감|산출)/.test(ctx);
}

function extractDamageRollDeltaSplit(text, lv) {
  var src = normalizeEffectText(text);
  if (!src) return { flat: 0, dice: 0 };
  var flat = 0, dice = 0;
  var seen = {};
  getUniqueRegexMatches(src, [
    new RegExp('그\\s*대미지(?:\\s*롤)?(?!\\s*가).{0,24}?([+\\-])\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')', 'gi'),
    new RegExp('공격(?:의)?\\s*대미지(?:\\s*롤)?(?!\\s*가).{0,24}?([+\\-])\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')', 'gi'),
    new RegExp('대미지(?:\\s*롤)?(?:를|가|는|은)?(?!\\s*가)[^.。!\\n\\r]{0,24}?([+\\-])\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')', 'gi')
  ]).forEach(function(m) {
    if (/^대미지/i.test(String(m[0] || '')) && shouldSkipGenericDamageRollMatch(src, m.index, m[0])) return;
    var sign = m[1] === '-' ? -1 : 1;
    var rawToken = m[2];
    var val = parseLvToken(rawToken, lv);
    if (isNaN(val)) return;
    var key = m.index + '|' + String(m[0] || '');
    if (seen[key]) return;
    seen[key] = 1;
    var delta = applyTrailingConstraintMeta(sign * val, rawToken, src.slice(m.index, Math.min(src.length, m.index + 80)));
    if (tokenHasDiceSuffix(rawToken)) dice += delta;
    else flat += delta;
  });
  return { flat: flat, dice: dice };
}

function extractHpDamageDelta(text, lv) {
  var src = normalizeEffectText(text);
  if (!src) return 0;
  var sum = 0;
  [
    new RegExp('HP\\s*대미지.{0,30}?([+\\-])\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')', 'gi'),
    new RegExp('([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:의)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,24}?(?:잃(?:는다|고|게)|상실)', 'gi'),
    new RegExp('(?:대상|상대|그\\s*대상|캐릭터)(?:에게|은|이|가)?[^.。!\\n\\r]{0,24}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:의)?\\s*HP\\s*대미지[^.。!\\n\\r]{0,20}?(?:가한다|입힌다|준다)', 'gi'),
    new RegExp('(?:대상|상대|그\\s*대상|캐릭터)(?:에게|은|이|가)?[^.。!\\n\\r]{0,24}?HP(?:를|가|는|은)?[^.。!\\n\\r]{0,12}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')[^.。!\\n\\r]{0,16}?(?:잃(?:는다|고|게)|상실)', 'gi'),
    new RegExp('(?:대상|상대|그\\s*대상|캐릭터)(?:에게|은|이|가)?[^.。!\\n\\r]{0,24}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:의)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,16}?(?:잃(?:는다|고|게)|상실)', 'gi')
  ].forEach(function(re) {
    re.lastIndex = 0;
    var m;
    while ((m = re.exec(src)) !== null) {
      var sign = m[1] === '-' ? -1 : 1;
      var rawToken = m[2];
      var val = parseLvTokenWithDiceFallback(rawToken, lv);
      if (!isNaN(val) && !tokenHasDiceSuffix(rawToken)) {
        sum += applyTrailingConstraintMeta(sign * val, rawToken, src.slice(m.index, Math.min(src.length, m.index + 80)));
      }
    }
  });
  return sum;
}

function extractHpDamageDiceCount(text, lv) {
  var src = normalizeEffectText(text);
  if (!src) return 0;
  var sum = 0;
  [
    new RegExp('HP\\s*대미지.{0,30}?([+\\-])\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')', 'gi'),
    new RegExp('([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:의)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,24}?(?:잃(?:는다|고|게)|상실)', 'gi'),
    new RegExp('(?:대상|상대|그\\s*대상|캐릭터)(?:에게|은|이|가)?[^.。!\\n\\r]{0,24}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:의)?\\s*HP\\s*대미지[^.。!\\n\\r]{0,20}?(?:가한다|입힌다|준다)', 'gi'),
    new RegExp('(?:대상|상대|그\\s*대상|캐릭터)(?:에게|은|이|가)?[^.。!\\n\\r]{0,24}?HP(?:를|가|는|은)?[^.。!\\n\\r]{0,12}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')[^.。!\\n\\r]{0,16}?(?:잃(?:는다|고|게)|상실)', 'gi'),
    new RegExp('(?:대상|상대|그\\s*대상|캐릭터)(?:에게|은|이|가)?[^.。!\\n\\r]{0,24}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:의)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,16}?(?:잃(?:는다|고|게)|상실)', 'gi')
  ].forEach(function(re) {
    re.lastIndex = 0;
    var m;
    while ((m = re.exec(src)) !== null) {
      var sign = m[1] === '-' ? -1 : 1;
      var diceCount = extractDiceCountFromToken(m[2], lv);
      if (diceCount !== 0) {
        sum += applyTrailingConstraintMeta(sign * diceCount, m[2], src.slice(m.index, Math.min(src.length, m.index + 80)));
      }
    }
  });
  return sum;
}

function extractHpRecoverDelta(text, lv) {
  var src = normalizeEffectText(text);
  if (!src) return 0;
  var sum = 0;
  var seen = {};
  getUniqueRegexMatches(src, [
    new RegExp('(?:당신|대상|상대|그\\s*대상|캐릭터|이\\s*에너미|자신)(?:의)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,12}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')[^.。!\\n\\r]{0,20}?회복', 'gi'),
    new RegExp('(?:당신|대상|상대|그\\s*대상|캐릭터|이\\s*에너미|자신)(?:에게|은|이|가)?[^.。!\\n\\r]{0,24}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:의)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,20}?회복', 'gi'),
    new RegExp('HP(?!\\s*대미지).{0,30}?([+\\-]?)\\s*(?=[\\[【(A-Za-z0-9])(' + STAT_VALUE_TOKEN_WITH_D + ').{0,20}?회복', 'gi'),
    new RegExp('(?:당신|대상|상대|그\\s*대상|캐릭터|이\\s*에너미|자신)(?:의)?\\s*HP(?:를|가|는|은)?\\s*([+\\-])\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*(?:점)?\\s*(?:한다|하고|하며|된다)', 'gi')
  ]).forEach(function(m) {
    if (hasMaxHpContext(src, m.index)) return;
    if (/까지\s*회복/.test(String(m[0] || ''))) return;
    var sign = (m[1] === '-') ? -1 : 1;
    var rawToken = m[2];
    var val = parseLvTokenWithDiceFallback(rawToken, lv);
    if (isNaN(val) || tokenHasDiceSuffix(rawToken)) return;
    var key = m.index + '|' + String(m[0] || '');
    if (seen[key]) return;
    seen[key] = 1;
    sum += applyTrailingConstraintMeta(sign * val, rawToken, src.slice(m.index, Math.min(src.length, m.index + 80)));
  });
  return sum;
}

function extractHpRecoverDiceCount(text, lv) {
  var src = normalizeEffectText(text);
  if (!src) return 0;
  var sum = 0;
  var seen = {};
  getUniqueRegexMatches(src, [
    new RegExp('(?:당신|대상|상대|그\\s*대상|캐릭터|이\\s*에너미|자신)(?:의)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,12}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')[^.。!\\n\\r]{0,20}?회복', 'gi'),
    new RegExp('(?:당신|대상|상대|그\\s*대상|캐릭터|이\\s*에너미|자신)(?:에게|은|이|가)?[^.。!\\n\\r]{0,24}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:의)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,20}?회복', 'gi'),
    new RegExp('HP(?!\\s*대미지).{0,30}?([+\\-]?)\\s*(?=[\\[【(A-Za-z0-9])(' + STAT_VALUE_TOKEN_WITH_D + ').{0,20}?회복', 'gi'),
    new RegExp('(?:당신|대상|상대|그\\s*대상|캐릭터|이\\s*에너미|자신)(?:의)?\\s*HP(?:를|가|는|은)?\\s*([+\\-])\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*(?:점)?\\s*(?:한다|하고|하며|된다)', 'gi')
  ]).forEach(function(m) {
    if (hasMaxHpContext(src, m.index)) return;
    var sign = (m[1] === '-') ? -1 : 1;
    var diceCount = extractDiceCountFromToken(m[2], lv);
    if (diceCount === 0) return;
    var key = m.index + '|' + String(m[0] || '');
    if (seen[key]) return;
    seen[key] = 1;
    sum += applyTrailingConstraintMeta(sign * diceCount, m[2], src.slice(m.index, Math.min(src.length, m.index + 80)));
  });
  return sum;
}

function extractHpConsumeDelta(text, lv) {
  var src = normalizeEffectText(text);
  if (!src) return 0;
  var sum = 0;
  var seen = {};
  getUniqueRegexMatches(src, [
    new RegExp('(?:당신|이\\s*에너미|자신|사용한\\s*캐릭터|이\\s*이펙트를\\s*사용한\\s*캐릭터)(?:에게|은|는|이|가)?[^.。!\\n\\r]{0,24}?HP(?:를|가|는|은)?[^.。!\\n\\r]{0,12}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')[^.。!\\n\\r]{0,16}?(?:소비|상실|잃(?:는다|고|게))', 'gi'),
    new RegExp('(?:당신|이\\s*에너미|자신|사용한\\s*캐릭터|이\\s*이펙트를\\s*사용한\\s*캐릭터)(?:에게|은|는|이|가)?[^.。!\\n\\r]{0,24}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:의)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,16}?(?:소비|상실|잃(?:는다|고|게))', 'gi'),
    new RegExp('(?:사용(?:시|하면|한\\s*후)?|메인\\s*프로세스[^.。!\\n\\r]{0,20}?(?:종료|마쳤|끝나)|판정[^.。!\\n\\r]{0,10}?후)[^.。!\\n\\r]{0,28}?HP(?:를|가|는|은)?[^.。!\\n\\r]{0,12}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')[^.。!\\n\\r]{0,16}?(?:소비|상실|잃(?:는다|고|게))', 'gi'),
    new RegExp('(?:사용(?:시|하면|한\\s*후)?|메인\\s*프로세스[^.。!\\n\\r]{0,20}?(?:종료|마쳤|끝나)|판정[^.。!\\n\\r]{0,10}?후)[^.。!\\n\\r]{0,28}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:의)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,16}?(?:소비|상실|잃(?:는다|고|게))', 'gi'),
    new RegExp('([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:까지|이하(?:로)?|만큼)?[^.。!\\n\\r]{0,16}?(?:임의의|원하는\\s*만큼)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,16}?(?:소비|상실|잃(?:는다|고|게))', 'gi'),
    new RegExp('(?:당신|이\\s*에너미|자신|사용한\\s*캐릭터|이\\s*이펙트를\\s*사용한\\s*캐릭터|사용(?:시|하면|한\\s*후)?|메인\\s*프로세스[^.。!\\n\\r]{0,20}?(?:종료|마쳤|끝나)|판정[^.。!\\n\\r]{0,10}?후)[^.。!\\n\\r]{0,28}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:까지|이하(?:로)?|만큼)?[^.。!\\n\\r]{0,16}?HP(?:를|가|는|은)?[^.。!\\n\\r]{0,16}?(?:소비|상실|잃(?:는다|고|게))', 'gi')
  ]).forEach(function(m) {
    var sign = m[1] === '-' ? -1 : 1;
    var rawToken = m[2];
    var val = parseLvTokenWithDiceFallback(rawToken, lv);
    if (isNaN(val) || tokenHasDiceSuffix(rawToken)) return;
    var key = m.index + '|' + String(m[0] || '');
    if (seen[key]) return;
    seen[key] = 1;
    sum += applyTrailingConstraintMeta(sign * val, rawToken, src.slice(m.index, Math.min(src.length, m.index + 80)));
  });
  return sum;
}

function extractHpConsumeDiceCount(text, lv) {
  var src = normalizeEffectText(text);
  if (!src) return 0;
  var sum = 0;
  var seen = {};
  getUniqueRegexMatches(src, [
    new RegExp('(?:당신|이\\s*에너미|자신|사용한\\s*캐릭터|이\\s*이펙트를\\s*사용한\\s*캐릭터)(?:에게|은|는|이|가)?[^.。!\\n\\r]{0,24}?HP(?:를|가|는|은)?[^.。!\\n\\r]{0,12}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')[^.。!\\n\\r]{0,16}?(?:소비|상실|잃(?:는다|고|게))', 'gi'),
    new RegExp('(?:당신|이\\s*에너미|자신|사용한\\s*캐릭터|이\\s*이펙트를\\s*사용한\\s*캐릭터)(?:에게|은|는|이|가)?[^.。!\\n\\r]{0,24}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:의)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,16}?(?:소비|상실|잃(?:는다|고|게))', 'gi'),
    new RegExp('(?:사용(?:시|하면|한\\s*후)?|메인\\s*프로세스[^.。!\\n\\r]{0,20}?(?:종료|마쳤|끝나)|판정[^.。!\\n\\r]{0,10}?후)[^.。!\\n\\r]{0,28}?HP(?:를|가|는|은)?[^.。!\\n\\r]{0,12}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')[^.。!\\n\\r]{0,16}?(?:소비|상실|잃(?:는다|고|게))', 'gi'),
    new RegExp('(?:사용(?:시|하면|한\\s*후)?|메인\\s*프로세스[^.。!\\n\\r]{0,20}?(?:종료|마쳤|끝나)|판정[^.。!\\n\\r]{0,10}?후)[^.。!\\n\\r]{0,28}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:의)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,16}?(?:소비|상실|잃(?:는다|고|게))', 'gi'),
    new RegExp('([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:까지|이하(?:로)?|만큼)?[^.。!\\n\\r]{0,16}?(?:임의의|원하는\\s*만큼)?\\s*HP(?:를|가|는|은)?[^.。!\\n\\r]{0,16}?(?:소비|상실|잃(?:는다|고|게))', 'gi'),
    new RegExp('(?:당신|이\\s*에너미|자신|사용한\\s*캐릭터|이\\s*이펙트를\\s*사용한\\s*캐릭터|사용(?:시|하면|한\\s*후)?|메인\\s*프로세스[^.。!\\n\\r]{0,20}?(?:종료|마쳤|끝나)|판정[^.。!\\n\\r]{0,10}?후)[^.。!\\n\\r]{0,28}?([+\\-]?)\\s*(' + STAT_VALUE_TOKEN_WITH_D + ')\\s*점?(?:까지|이하(?:로)?|만큼)?[^.。!\\n\\r]{0,16}?HP(?:를|가|는|은)?[^.。!\\n\\r]{0,16}?(?:소비|상실|잃(?:는다|고|게))', 'gi')
  ]).forEach(function(m) {
    var sign = m[1] === '-' ? -1 : 1;
    var diceCount = extractDiceCountFromToken(m[2], lv);
    if (diceCount === 0) return;
    var key = m.index + '|' + String(m[0] || '');
    if (seen[key]) return;
    seen[key] = 1;
    sum += applyTrailingConstraintMeta(sign * diceCount, m[2], src.slice(m.index, Math.min(src.length, m.index + 80)));
  });
  return sum;
}

function hasReactionForbidden(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  return /리액션.{0,8}?할\s*수(?:도)?\s*없다/i.test(src);
}

function hasDodgeForbidden(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  return /닷지를?\s*(?:선택할\s*수|할\s*수|행할\s*수|시행할\s*수)\s*없다/i.test(src);
}

function hasDodgeDiceRollForbidden(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  return /닷지\s*판정(?:으로)?\s*다이스\s*롤을?\s*할\s*수\s*없다/i.test(src);
}

function hasGuardForbidden(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  return /가드를?\s*(?:할\s*수|행할\s*수|시행할\s*수)\s*없다/i.test(src);
}

function hasCoverForbidden(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  return /커버링을\s*할\s*수\s*없다|커버링\s*대상이\s*될\s*수\s*없다/i.test(src);
}

function hasCoverAsNoGuard(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  return /커버링.{0,60}?(?:가드를?\s*(?:한|실시한)\s*것(?:처럼|으로(?:서|써)?|으로\s*여겨)?\s*(?:하여|해)?\s*대미지를\s*산출(?:해낼)?\s*수\s*없다|가드를?\s*(?:하지\s*않은|실시하지\s*않은)\s*것(?:으로)?\s*간주해?\s*대미지를\s*산출)/i.test(src);
}

function hasPriorityInitiativeZero(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  var hasZeroSentence = /행동치.{0,30}?0\s*이\s*된다/i.test(src) || /【행동치】.{0,30}?0\s*이\s*된다/i.test(src);
  var hasPriorityWord = /우선/i.test(src);
  return hasZeroSentence || (hasPriorityWord && /행동치/i.test(src) && /0/.test(src));
}

function hasDicePenaltyImmunity(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  return /다이스.{0,30}?감소.{0,30}?받지\s*않는다/i.test(src);
}

function hasIgnoreTargetArmor(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  return /장갑치.{0,20}?무시/i.test(src) || /장갑.{0,20}?무시/i.test(src);
}
function hasClearFlight(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  return /비행상태.{0,12}?해제/i.test(src);
}
function hasSameEngageForbidden(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  return /같은\s*인게이지.{0,36}?대상.{0,20}?할\s*수\s*없다/i.test(src);
}
function hasSameEngageAllowed(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  return /같은\s*인게이지.{0,36}?(사용|대상).{0,20}?할\s*수\s*있다/i.test(src);
}

function isNoErosionLevelUp(text) {
  var src = normalizeEffectText(text);
  if (!src) return false;
  return /침식률.{0,24}?레벨업.{0,16}?않(?:는다|고)/i.test(src);
}

function extractCriticalFloor(text) {
  var src = normalizeEffectText(text);
  if (!src) return null;
  if (!/크리티컬/.test(src)) return null;
  var m = /하한(?:치|은|을|이)?.{0,4}?([0-9]+)/i.exec(src);
  if (!m) return null;
  var n = parseInt(m[1], 10);
  return isNaN(n) ? null : n;
}

function getAppliedEffectLv(key, ef, effLv) {
  var baseLv = (+ef.lv || 0);
  if (key && isLvBonusExceptionEnabled(key, ef)) return baseLv;
  if (!key && isNoErosionLevelUp(ef.effect)) return baseLv;
  return baseLv + effLv;
}

function getCopyAppliedEffectLv(key, ef, effLv, useErosionEffectLvBonus) {
  if (useErosionEffectLvBonus === false) return (+((ef || {}).lv) || 0);
  return getAppliedEffectLv(key, ef, effLv);
}

function ensureCocoState() {
  if (!COCO_STATE || typeof COCO_STATE !== 'object') COCO_STATE = defaultCocoState();
  COCO_STATE.includeEffectText = !!COCO_STATE.includeEffectText;
  if (typeof COCO_STATE.useErosionDiceBonus !== 'boolean') {
    if (typeof COCO_STATE.useErosionBonus === 'boolean') COCO_STATE.useErosionDiceBonus = COCO_STATE.useErosionBonus;
    else COCO_STATE.useErosionDiceBonus = true;
  }
  if (typeof COCO_STATE.useErosionEffectLvBonus !== 'boolean') {
    if (typeof COCO_STATE.useEffectBonus === 'boolean') COCO_STATE.useErosionEffectLvBonus = COCO_STATE.useEffectBonus;
    else if (typeof COCO_STATE.useItemStatsBonus === 'boolean') COCO_STATE.useErosionEffectLvBonus = COCO_STATE.useItemStatsBonus;
    else COCO_STATE.useErosionEffectLvBonus = true;
  }
  if (typeof COCO_STATE.useItemBonuses !== 'boolean') COCO_STATE.useItemBonuses = true;
  if (Object.prototype.hasOwnProperty.call(COCO_STATE, 'useErosionBonus')) delete COCO_STATE.useErosionBonus;
  if (Object.prototype.hasOwnProperty.call(COCO_STATE, 'useEffectBonus')) delete COCO_STATE.useEffectBonus;
  if (Object.prototype.hasOwnProperty.call(COCO_STATE, 'useItemStatsBonus')) delete COCO_STATE.useItemStatsBonus;
  if (typeof COCO_STATE.memoText !== 'string') COCO_STATE.memoText = '';
  if (typeof COCO_STATE.memoPre !== 'string') COCO_STATE.memoPre = '';
  if (typeof COCO_STATE.memoSuf !== 'string') COCO_STATE.memoSuf = '';
  if (!Array.isArray(COCO_STATE.combos)) COCO_STATE.combos = [];
  COCO_STATE.combos = COCO_STATE.combos.map(function(c, i) { return normalizeCocoComboEntry(c, i); });
  if (typeof COCO_STATE.draftName !== 'string') COCO_STATE.draftName = '';
  if (typeof COCO_STATE.separatorText !== 'string') COCO_STATE.separatorText = ' | ';
  return COCO_STATE;
}

function decodeEscapedLineBreaks(text) {
  return String(text == null ? '' : text)
    .replace(/\\r\\n/g, '\n')
    .replace(/\\n/g, '\n')
    .replace(/\\r/g, '\n');
}

function buildCocoComboText(ctx) {
  var effectsPart = (ctx.effects || []).map(function(e) {
    return '\u300a' + e.name + '\u300b' + (e.lvText ? ('Lv' + e.lvText) : '');
  }).join(' + ');
  if (!effectsPart) effectsPart = '\u2015';
  var fmtCocoStat = function(flat, dice) {
    return (dice != null && dice !== 0) ? fmtStatWithDice(flat, dice) : String(flat);
  };
  var isZeroDice = function(flat, dice) { return flat === 0 && (!dice || dice === 0); };
  var tokens = [effectsPart];
  if (ctx.action && ctx.action !== '\u2015') tokens.push('\ud310\uc815: ' + ctx.action);
  if (ctx.range && ctx.range !== '\u2015' && ctx.range !== '-') tokens.push('\uc0ac\uc815: ' + ctx.range);
  if (ctx.target && ctx.target !== '\u2015' && ctx.target !== '-') tokens.push('\ub300\uc0c1: ' + ctx.target);
  if (ctx.crit != null && ctx.crit !== 10) tokens.push('\ud06c\ub9ac\ud2f0\uceec\uce58: ' + ctx.crit);
  if (ctx.dice != null && ctx.dice !== 0) tokens.push('다이스: ' + ctx.dice);
  if (!isZeroDice(ctx.hit, ctx.hitDice)) tokens.push('\ub2ec\uc131\uce58: ' + fmtCocoStat(ctx.hit, ctx.hitDice));
  if (!isZeroDice(ctx.attack, ctx.attackDice)) tokens.push('\uacf5\uaca9\ub825: ' + fmtCocoStat(ctx.attack, ctx.attackDice));
  if (!isZeroDice(ctx.guard, ctx.guardDice)) tokens.push('\uac00\ub4dc\uce58: ' + fmtCocoStat(ctx.guard, ctx.guardDice));
  var sep = decodeEscapedLineBreaks((typeof ctx.separatorText === 'string') ? ctx.separatorText : ' | ');
  var head = tokens.join(sep);
  var detailRows = (ctx.effects || []).filter(function(e) {
    return !!e.effectText;
  }).map(function(e) {
    return '\u300a' + e.name + '\u300b: ' + decodeEscapedLineBreaks(e.effectText);
  });
  var out = head;
  if (ctx.erosion) out += '\n\uce68\uc2dd\uce58: ' + fmtSigned(ctx.erosion);
  if (ctx.includeEffectText && detailRows.length) out += '\n' + detailRows.join(sep);
  var memo = decodeEscapedLineBreaks(ctx.memoText || '').trim();
  if (memo) {
    var mPre = (typeof ctx.memoPre === 'string') ? ctx.memoPre : '\u3010';
    var mSuf = (typeof ctx.memoSuf === 'string') ? ctx.memoSuf : '\u3011';
    out += '\n' + mPre + memo + mSuf;
  }
  var comboName = String(ctx.comboName || '').trim();
  if (comboName) out = '\u3010' + comboName + '\u3011\n' + out;
  return out;
}

function cloneJsonSafe(value, fallback) {
  try { return JSON.parse(JSON.stringify(value)); } catch (_) {}
  return fallback;
}

function normalizeEffectKeyList(selKeys) {
  var out = [];
  var seen = {};
  (selKeys || []).forEach(function(rawKey) {
    var key = resolveEffectKeyFromState(rawKey);
    if (!key || seen[key]) return;
    seen[key] = true;
    out.push(key);
  });
  return out;
}

function buildEffectCustomSnapshot(selKeys, source) {
  var out = {};
  var src = (source && typeof source === 'object') ? source : {};
  normalizeEffectKeyList(selKeys).forEach(function(key) {
    var state = src[key];
    if (!state || typeof state !== 'object') return;
    var entry = {};
    if (Array.isArray(state.manual) && state.manual.length) {
      entry.manual = state.manual.map(function(rule) {
        return {
          field: String((rule && rule.field) || 'dice'),
          expr: String((rule && rule.expr) || '')
        };
      });
    }
    if (state.autoExclude && typeof state.autoExclude === 'object') {
      var autoExclude = {};
      Object.keys(state.autoExclude).sort().forEach(function(field) {
        if (state.autoExclude[field]) autoExclude[field] = true;
      });
      if (Object.keys(autoExclude).length) entry.autoExclude = autoExclude;
    }
    if (typeof state.lvBonusOff === 'boolean') entry.lvBonusOff = state.lvBonusOff;
    if (Object.keys(entry).length) out[key] = entry;
  });
  return out;
}

function buildEffectCondSnapshot(selKeys, source) {
  var out = {};
  var src = (source && typeof source === 'object') ? source : {};
  normalizeEffectKeyList(selKeys).forEach(function(key) {
    var state = src[key];
    if (!state || typeof state !== 'object' || !state.values || typeof state.values !== 'object') return;
    var values = {};
    Object.keys(state.values).sort().forEach(function(condKey) {
      if (typeof state.values[condKey] === 'boolean') values[condKey] = state.values[condKey];
    });
    if (Object.keys(values).length) out[key] = { values: values };
  });
  return out;
}

function withEffectStateSources(customState, condState, fn) {
  var prevCustom = EF_CUSTOM;
  var prevCond = EF_COND;
  EF_CUSTOM = cloneJsonSafe(customState || {}, {});
  EF_COND = cloneJsonSafe(condState || {}, {});
  try {
    return fn();
  } finally {
    EF_CUSTOM = prevCustom;
    EF_COND = prevCond;
  }
}

function buildSavedComboSnapshot(args) {
  var a = args || {};
  return {
    version: 2,
    selKeys: Array.isArray(a.selKeys) ? a.selKeys.slice() : [],
    featRaw: String(a.featRaw || 'NONE'),
    modRaw: String(a.modRaw || 'NONE'),
    rangeVal: String(a.rangeVal || RANGE_OPTIONS[0] || '\u2015'),
    targetVal: String(a.targetVal || TARGET_OPTIONS[0] || '\u2015'),
    includeEffectText: !!a.includeEffectText,
    memoText: String(a.memoText || ''),
    memoPre: typeof a.memoPre === 'string' ? a.memoPre : '',
    memoSuf: typeof a.memoSuf === 'string' ? a.memoSuf : '',
    separatorText: String(a.separatorText || ' | '),
    comboName: String(a.comboName || ''),
    eqsel: cloneJsonSafe(a.eqsel || EQSEL, { weapon: {}, armor: {}, vehicle: {} }),
    customEquip: cloneJsonSafe(a.customEquip || CUSTOM_EQUIP, { weapon: [], armor: [], vehicle: [] }),
    extraAdj: cloneJsonSafe(a.extraAdj || ensureExtraAdj(), defaultExtraAdj()),
    effectTexts: buildEffectTextState(a.selKeys),
    efCustom: buildEffectCustomSnapshot(a.selKeys, a.efCustom || EF_CUSTOM),
    efCond: buildEffectCondSnapshot(a.selKeys, a.efCond || EF_COND)
  };
}

function buildCocoTextFromSnapshot(snapshot, er) {
  if (!snapshot || typeof snapshot !== 'object' || !G) return '';
  var effectCustomSnapshot = (snapshot.efCustom && typeof snapshot.efCustom === 'object') ? snapshot.efCustom : null;
  var effectCondSnapshot = (snapshot.efCond && typeof snapshot.efCond === 'object') ? snapshot.efCond : null;
  var run = function() {
    var useEr = (typeof er === 'number' && !isNaN(er)) ? er : (+((document.getElementById('inp-er') || {}).value || 0));
    var cocoState = ensureCocoState();
    var useErosionDiceBonus = (cocoState.useErosionDiceBonus !== false);
    var useErosionEffectLvBonus = (cocoState.useErosionEffectLvBonus !== false);
    var useItemBonuses = (cocoState.useItemBonuses !== false);
    var dp = calcDP(useEr);
    var effLv = calcEL(useEr);
    var featRaw = String(snapshot.featRaw || 'NONE');
    var modRaw = String(snapshot.modRaw || 'NONE');
    var rangeVal = String(snapshot.rangeVal || RANGE_OPTIONS[0] || '\u2015');
    var targetVal = String(snapshot.targetVal || TARGET_OPTIONS[0] || '\u2015');
    var selKeys = Array.isArray(snapshot.selKeys) ? snapshot.selKeys : [];
    var selected = [];
    selKeys.forEach(function(rawKey) {
      var key = resolveEffectKeyFromState(rawKey);
      if (!key) return;
      var ef = getEffectByKey(key);
      if (!ef) return;
      selected.push({ key: key, ef: ef });
    });

    var feat = parseFeat(featRaw);
    var statIdx = feat.statIdx;
    var skillVal = feat.skillVal;
    var statVal = statIdx >= 0 ? G.statValues[STAT_KEYS[statIdx]] : 0;
    var finalStat = statVal;
    if (modRaw !== 'NONE') {
      var mod = parseFeat(modRaw);
      if (mod.statIdx >= 0) finalStat = G.statValues[STAT_KEYS[mod.statIdx]];
    }

    var fx = {
      critical: 0,
      criticalFloor: 7,
      hit: 0,
      achievement: 0,
      dice: 0,
      dicePenaltyImmune: false,
      attack: 0,
      guard: 0,
      armor: 0,
      initiative: 0,
      hp: 0,
      hpDamage: 0,
      erosion: 0,
      initiativeFixedZero: false,
      attackDice: 0,
      guardDice: 0,
      armorDice: 0,
      hitDice: 0,
      achievementDice: 0,
      initDice: 0,
      critDice: 0
    };
    var totalEr = 0;
    var dicePos = 0;
    var diceNeg = 0;

    selected.forEach(function(s) {
      var ef = s.ef || {};
      var effectLv = getAppliedEffectLv(s.key, ef, effLv);
      var parsedBundle = getEffectParsedBundle(s.key, ef, effectLv);
      var txt = parsedBundle.text;
      var autoMods = parsedBundle.autoMods;

      fx.critical += autoMods.critical;
      fx.hit += autoMods.hit;
      fx.achievement += (autoMods.achievement || 0);
      if (autoMods.dice >= 0) dicePos += autoMods.dice;
      else diceNeg += autoMods.dice;
      fx.attack += autoMods.attack;
      fx.guard += autoMods.guard;
      fx.armor += autoMods.armor;
      fx.initiative += autoMods.initiative;
      fx.hp += autoMods.hp;
      fx.hpDamage += autoMods.hpDamage;
      fx.erosion += (autoMods.erosion || 0);
      if (autoMods.criticalFloor !== null) fx.criticalFloor = Math.min(fx.criticalFloor, autoMods.criticalFloor);
      if (autoMods.dicePenaltyImmune) fx.dicePenaltyImmune = true;
      if (autoMods.initiativeFixedZero) fx.initiativeFixedZero = true;
      fx.attackDice += (autoMods.attackDice || 0);
      fx.guardDice += (autoMods.guardDice || 0);
      fx.armorDice += (autoMods.armorDice || 0);
      fx.hitDice += (autoMods.hitDice || 0);
      fx.achievementDice += (autoMods.achievementDice || 0);
      fx.initDice += (autoMods.initDice || 0);
      fx.critDice += (autoMods.critDice || 0);

      var manualMods = parsedBundle.manualMods;
      fx.critical += manualMods.critical;
      fx.hit += manualMods.hit;
      fx.achievement += (manualMods.achievement || 0);
      if (manualMods.dice >= 0) dicePos += manualMods.dice;
      else diceNeg += manualMods.dice;
      fx.attack += manualMods.attack;
      fx.guard += manualMods.guard;
      fx.armor += manualMods.armor;
      fx.initiative += manualMods.initiative;
      fx.hp += manualMods.hp;
      fx.hpDamage += manualMods.hpDamage;
      fx.erosion += (manualMods.erosion || 0);
      if (manualMods.criticalFloor !== null) fx.criticalFloor = Math.min(fx.criticalFloor, manualMods.criticalFloor);
      fx.attackDice += (manualMods.attackDice || 0);
      fx.guardDice += (manualMods.guardDice || 0);
      fx.armorDice += (manualMods.armorDice || 0);
      fx.hitDice += (manualMods.hitDice || 0);
      fx.achievementDice += (manualMods.achievementDice || 0);
      fx.initDice += (manualMods.initDice || 0);
      fx.critDice += (manualMods.critDice || 0);
      var mTags = manualMods.tags || {};
      if (mTags.dicePenaltyImmune) fx.dicePenaltyImmune = true;
      if (mTags.initiativeFixedZero) fx.initiativeFixedZero = true;
      if (mTags.reactionForbidden) fx.reactionForbidden = true;
      if (mTags.dodgeForbidden) fx.dodgeForbidden = true;
      if (mTags.dodgeNoDiceRoll) fx.dodgeNoDiceRoll = true;
      if (mTags.guardForbidden) fx.guardForbidden = true;
      if (mTags.coverForbidden) fx.coverForbidden = true;
      if (mTags.coverAsNoGuard) fx.coverAsNoGuard = true;
      if (mTags.ignoreTargetArmor) fx.ignoreTargetArmor = true;
      if (mTags.clearFlight) fx.clearFlight = true;
      if (mTags.sameEngageForbidden) fx.sameEngageForbidden = true;
      if (mTags.sameEngageAllowed) fx.sameEngageAllowed = true;

      if (/크리티컬치/i.test(txt) && /-\s*\[\s*LV\s*\]/i.test(txt) && autoMods.critical === 0) fx.critical -= effectLv;
      if (txt.indexOf('다이스를 +[LV+2]') >= 0 && autoMods.dice === 0) dicePos += effectLv + 2;
      var erNum = parseFloat(ef.erosion);
      if (!isNaN(erNum)) totalEr += erNum;
    });
    var extraAdj = (snapshot.extraAdj && typeof snapshot.extraAdj === 'object') ? snapshot.extraAdj : defaultExtraAdj();
    (extraAdj.groups || []).forEach(function(group) {
      if (!group || group.enabled === false) return;
      (group.rows || []).forEach(function(row) {
        var extraAdjVal = parseExtraAdjValue((row || {}).value);
        if (isNaN(extraAdjVal) || extraAdjVal === 0) return;
        var extraAdjField = normalizeBonusFieldKey(resolveManualRuleField((row && row.field) ? row.field : 'achievement', (row || {}).value));
        if (extraAdjField === 'criticalFloor') {
          fx.criticalFloor = Math.min(fx.criticalFloor, extraAdjVal);
        } else if (extraAdjField === 'dice') {
          if (extraAdjVal >= 0) dicePos += extraAdjVal;
          else diceNeg += extraAdjVal;
        } else if (extraAdjField === 'erosion') {
          fx.erosion += extraAdjVal;
        } else if (Object.prototype.hasOwnProperty.call(fx, extraAdjField)) {
          fx[extraAdjField] += extraAdjVal;
        }
      });
    });
    fx.dice = dicePos + (fx.dicePenaltyImmune ? 0 : diceNeg);
    var copyOutput = buildCopyOutputFromEffects(selected, effLv, useErosionEffectLvBonus, extraAdj);

    var eqsel = normalizeEqSelectionForCurrentData(snapshot.eqsel);
    function isChecked(type, item, idx) {
      var key = getItemStableKey(type, item, idx);
      return !(eqsel && eqsel[type] && eqsel[type][key] === false);
    }
    var equipHit = 0, equipAttack = 0, equipGuard = 0, equipArmor = 0;
    if (useItemBonuses) {
      (G.weapons || []).forEach(function(w, i) {
        if (!isChecked('weapon', w, i)) return;
        var m = +w['명중']; if (!isNaN(m)) equipHit += m;
        var atk = +w['공격력']; if (!isNaN(atk)) equipAttack += atk;
        var grd = +w['가드치']; if (!isNaN(grd)) equipGuard += grd;
      });
      (G.armors || []).forEach(function(a, i) {
        if (!isChecked('armor', a, i)) return;
        var arm = +a['장갑']; if (!isNaN(arm)) equipArmor += arm;
      });
      (G.vehicles || []).forEach(function(v, i) {
        if (!isChecked('vehicle', v, i)) return;
        var vatk = +v['공격력']; if (!isNaN(vatk)) equipAttack += vatk;
        var varm = +v['장갑']; if (!isNaN(varm)) equipArmor += varm;
      });
    }
    var ceq = snapshot.customEquip || CUSTOM_EQUIP || { weapon: [], armor: [], vehicle: [] };
    if (useItemBonuses) {
      (ceq.weapon || []).forEach(function(w) {
        if (w.checked === false) return;
        var m = +w.hit; if (!isNaN(m)) equipHit += m;
        var atk = +w.attack; if (!isNaN(atk)) equipAttack += atk;
        var grd = +w.guard; if (!isNaN(grd)) equipGuard += grd;
      });
      (ceq.armor || []).forEach(function(a) {
        if (a.checked === false) return;
        var arm = +a.armor; if (!isNaN(arm)) equipArmor += arm;
      });
      (ceq.vehicle || []).forEach(function(v) {
        if (v.checked === false) return;
        var vatk = +v.attack; if (!isNaN(vatk)) equipAttack += vatk;
        var varm = +v.armor; if (!isNaN(varm)) equipArmor += varm;
      });
    }

    var comboEffects = copyOutput.effects;
    var totalErosion = totalEr + (copyOutput.mods.erosion || 0);
    var copyMetrics = buildCopyOutputMetrics({
      outputMods: copyOutput.mods,
      finalStat: finalStat,
      useErosionDiceBonus: useErosionDiceBonus,
      dp: dp,
      skillVal: skillVal,
      equipHit: equipHit,
      equipAttack: equipAttack,
      equipGuard: equipGuard,
      hasAction: featRaw !== 'NONE'
    });
    return buildCocoComboText({
      comboName: String(snapshot.comboName || ''),
      effects: comboEffects,
      includeEffectText: !!snapshot.includeEffectText,
      memoText: String(snapshot.memoText || ''),
      memoPre: typeof snapshot.memoPre === 'string' ? snapshot.memoPre : '',
      memoSuf: typeof snapshot.memoSuf === 'string' ? snapshot.memoSuf : '',
      separatorText: String(snapshot.separatorText || ' | '),
      action: (featRaw !== 'NONE') ? getFeatLabel(featRaw) : '\u2015',
      range: rangeVal,
      target: targetVal,
      crit: copyMetrics.crit,
      dice: copyMetrics.totalDice,
      hit: copyMetrics.hit,
      hitDice: copyMetrics.totalHitDice,
      attack: copyMetrics.attack,
      attackDice: copyMetrics.attackDice,
      guard: copyMetrics.guard,
      guardDice: copyMetrics.guardDice,
      erosion: totalErosion || 0
    });
  };
  var withState = function() {
    if (!effectCustomSnapshot && !effectCondSnapshot) return run();
    return withEffectStateSources(effectCustomSnapshot || {}, effectCondSnapshot || {}, run);
  };
  return withSnapshotEffectTexts(snapshot, withState);
}

function getRuntimeEffectStateSignature(selKeys) {
  var keys = Array.isArray(selKeys) ? selKeys : [];
  var out = [];
  keys.forEach(function(rawKey) {
    var key = resolveEffectKeyFromState(rawKey);
    if (!key) return;
    out.push(String(key) + '#' + getCondStateSignature(key) + '#' + getManualStateSignature(key) + '#' + getAutoExcludeSignature(key));
  });
  return out.join('||');
}

function buildSavedComboTextCacheKey(snapshot, er) {
  var erVal = (typeof er === 'number' && !isNaN(er)) ? er : (+((document.getElementById('inp-er') || {}).value || 0));
  var cocoState = ensureCocoState();
  var erosionDiceOn = String(cocoState.useErosionDiceBonus !== false);
  var erosionEffectLvOn = String(cocoState.useErosionEffectLvBonus !== false);
  var itemBonusesOn = String(cocoState.useItemBonuses !== false);
  var snapRaw = '';
  try { snapRaw = JSON.stringify(snapshot || {}); } catch (_) { snapRaw = ''; }
  return String(CUR_SHEET || '') + '|' + String(CUR_DATA_VERSION || '') + '|' + String(erVal) + '|' + erosionDiceOn + '|' + erosionEffectLvOn + '|' + itemBonusesOn + '|' + snapRaw;
}

function setSavedComboTextCacheEntry(cacheKey, text) {
  if (!cacheKey) return;
  if (!Object.prototype.hasOwnProperty.call(_SAVED_COCO_TEXT_CACHE, cacheKey)) _SAVED_COCO_TEXT_CACHE_SIZE += 1;
  _SAVED_COCO_TEXT_CACHE[cacheKey] = text;
  if (_SAVED_COCO_TEXT_CACHE_SIZE <= _SAVED_COCO_TEXT_CACHE_LIMIT) return;
  _SAVED_COCO_TEXT_CACHE = {};
  _SAVED_COCO_TEXT_CACHE_SIZE = 0;
}

function getSavedComboLiveText(combo, er) {
  var c = (combo && typeof combo === 'object') ? combo : {};
  if (c.snapshot && typeof c.snapshot === 'object') {
    var cacheKey = buildSavedComboTextCacheKey(c.snapshot, er);
    if (cacheKey && Object.prototype.hasOwnProperty.call(_SAVED_COCO_TEXT_CACHE, cacheKey)) {
      return _SAVED_COCO_TEXT_CACHE[cacheKey];
    }
    var next = buildCocoTextFromSnapshot(c.snapshot, er);
    if (next) {
      setSavedComboTextCacheEntry(cacheKey, next);
      return next;
    }
  }
  return String(c.text || '');
}

function createCopyOutputState() {
  return {
    critical: 0,
    criticalFloor: 7,
    hit: 0,
    achievement: 0,
    dicePos: 0,
    diceNeg: 0,
    dicePenaltyImmune: false,
    attack: 0,
    guard: 0,
    attackDice: 0,
    guardDice: 0,
    hitDice: 0,
    achievementDice: 0,
    erosion: 0
  };
}

function applyEffectParsedBundleToCopyOutput(state, parsedBundle, effectLv) {
  if (!state || !parsedBundle) return;
  var txt = String(parsedBundle.text || '');
  var autoMods = parsedBundle.autoMods || {};
  var manualMods = parsedBundle.manualMods || {};
  function applyMods(mods) {
    state.critical += (mods.critical || 0);
    state.hit += (mods.hit || 0);
    state.achievement += (mods.achievement || 0);
    if ((mods.dice || 0) >= 0) state.dicePos += (mods.dice || 0);
    else state.diceNeg += (mods.dice || 0);
    state.attack += (mods.attack || 0);
    state.guard += (mods.guard || 0);
    state.attackDice += (mods.attackDice || 0);
    state.guardDice += (mods.guardDice || 0);
    state.hitDice += (mods.hitDice || 0);
    state.achievementDice += (mods.achievementDice || 0);
    state.erosion += (mods.erosion || 0);
    if (mods.criticalFloor !== null && mods.criticalFloor !== undefined) {
      state.criticalFloor = Math.min(state.criticalFloor, mods.criticalFloor);
    }
  }
  applyMods(autoMods);
  applyMods(manualMods);
  if (autoMods.dicePenaltyImmune) state.dicePenaltyImmune = true;
  if (/크리티컬치/i.test(txt) && /-\s*\[\s*LV\s*\]/i.test(txt) && (autoMods.critical || 0) === 0) {
    state.critical -= effectLv;
  }
  if (txt.indexOf('다이스를 +[LV+2]') >= 0 && (autoMods.dice || 0) === 0) {
    state.dicePos += effectLv + 2;
  }
}

function applyExtraAdjValueToCopyOutput(state, field, value) {
  if (!state) return;
  var resolvedField = resolveManualRuleField(field || 'achievement', value);
  var n = parseExtraAdjValue(value);
  if (isNaN(n) || n === 0) return;
  var key = normalizeBonusFieldKey(resolvedField || 'achievement');
  if (key === 'criticalFloor') {
    state.criticalFloor = Math.min(state.criticalFloor, n);
    return;
  }
  if (key === 'dice') {
    if (n >= 0) state.dicePos += n;
    else state.diceNeg += n;
    return;
  }
  if (Object.prototype.hasOwnProperty.call(state, key)) state[key] += n;
}

function finalizeCopyOutputState(state) {
  var src = state || createCopyOutputState();
  return {
    critical: src.critical || 0,
    criticalFloor: (src.criticalFloor != null) ? src.criticalFloor : 7,
    hit: src.hit || 0,
    achievement: src.achievement || 0,
    dice: (src.dicePos || 0) + ((src.dicePenaltyImmune ? 0 : (src.diceNeg || 0))),
    attack: src.attack || 0,
    guard: src.guard || 0,
    attackDice: src.attackDice || 0,
    guardDice: src.guardDice || 0,
    hitDice: src.hitDice || 0,
    achievementDice: src.achievementDice || 0,
    erosion: src.erosion || 0
  };
}

function buildCopyOutputFromEffects(selected, effLv, useErosionEffectLvBonus, extraAdj) {
  var state = createCopyOutputState();
  var effects = [];
  (selected || []).forEach(function(s) {
    var ef = s.ef || {};
    var effectLv = getCopyAppliedEffectLv(s.key, ef, effLv, useErosionEffectLvBonus);
    effects.push({
      key: s.key,
      name: String(ef.name || ''),
      lvText: (ef.lv !== '' && ef.lv !== null) ? String(effectLv) : '',
      effectText: String(ef.effect || '')
    });
    applyEffectParsedBundleToCopyOutput(state, getEffectParsedBundle(s.key, ef, effectLv), effectLv);
  });
  ((extraAdj && extraAdj.groups) || []).forEach(function(group) {
    if (!group || group.enabled === false) return;
    (group.rows || []).forEach(function(row) {
      applyExtraAdjValueToCopyOutput(state, (row && row.field) ? row.field : 'achievement', (row || {}).value);
    });
  });
  return {
    effects: effects,
    mods: finalizeCopyOutputState(state)
  };
}

function buildCopyOutputMetrics(args) {
  var a = args || {};
  var mods = a.outputMods || {};
  var critFloor = (mods.criticalFloor != null) ? mods.criticalFloor : 7;
  var crit = Math.max(critFloor, 10 + (mods.critical || 0));
  var totalDice = (a.finalStat || 0) + ((a.useErosionDiceBonus === false) ? 0 : (a.dp || 0)) + (mods.dice || 0);
  var totalHitDice = (mods.hitDice || 0) + (mods.achievementDice || 0);
  var hit = (a.skillVal || 0) + (a.equipHit || 0) + (mods.hit || 0) + (mods.achievement || 0);
  var attack = (a.equipAttack || 0) + (mods.attack || 0);
  var guard = (a.equipGuard || 0) + (mods.guard || 0);
  var formula = '\u2015';
  if (a.hasAction) {
    var critStr = crit !== 10 ? String(crit) : '';
    var bonusStr = hit > 0 ? '+' + hit : hit < 0 ? String(hit) : '';
    formula = totalDice + 'DX' + critStr + bonusStr;
  }
  return {
    crit: crit,
    totalDice: totalDice,
    totalHitDice: totalHitDice,
    hit: hit,
    attack: attack,
    attackDice: mods.attackDice || 0,
    guard: guard,
    guardDice: mods.guardDice || 0,
    formula: formula
  };
}

function copyTextToClipboard(text, doneCb) {
  if (!text) return;
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(text).then(function() {
      if (doneCb) doneCb(true);
    }).catch(function() {
      if (doneCb) doneCb(false);
    });
    return;
  }
  try {
    var ta = document.createElement('textarea');
    ta.value = text;
    ta.style.position = 'fixed';
    ta.style.left = '-9999px';
    document.body.appendChild(ta);
    ta.select();
    var ok = document.execCommand('copy');
    document.body.removeChild(ta);
    if (doneCb) doneCb(!!ok);
  } catch (_) {
    if (doneCb) doneCb(false);
  }
}

function flashDoneState(btn) {
  if (!btn) return;
  btn.classList.add('done');
  setTimeout(function() { btn.classList.remove('done'); }, 900);
}

function buildCalcMetricBlockHtml(label, id, cls) {
  return '<div class="r-blk"><div class="r-lbl">' + label + '</div><div id="' + id + '" class="r-val ' + (cls || '') + '">0</div></div>';
}

function buildCalcResultShellHtml() {
  var html = '';
  html += '<div class="dice-box"><div class="dice-top"><div id="calc-dice-formula" class="dice-formula">―</div><button type="button" id="btn-copy-dice" class="dice-copy" title="다이스 복사" aria-label="다이스 복사"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button></div>'
       + '</div>';
  html += '<div class="r-grid full">' + buildCalcMetricBlockHtml('침식치', 'calc-er-val', 'rv-red') + '</div>';
  html += buildErosionIgnoreBoxHtml();
  html += '<div class="r-grid compact">'
       + buildCalcMetricBlockHtml('크리티컬치', 'calc-crit-val', '')
       + buildCalcMetricBlockHtml('달성치', 'calc-hit-val', '')
       + buildCalcMetricBlockHtml('다이스', 'calc-dice-val', '')
       + buildCalcMetricBlockHtml('공격력', 'calc-atk-val', '')
       + buildCalcMetricBlockHtml('가드치', 'calc-guard-val', '')
       + buildCalcMetricBlockHtml('장갑치', 'calc-armor-val', '')
       + buildCalcMetricBlockHtml('행동치', 'calc-init-val', '')
       + buildCalcMetricBlockHtml('HP', 'calc-hp-val', '')
       + '</div>';
  html += '<div id="calc-tags" class="ptags"></div>';
  html += '<div class="coco-box">'
       + '<div class="coco-top"><div class="coco-hd">코코포리아 복사용</div><div class="coco-top-actions">'
       + '<button type="button" id="btn-refresh-coco" class="coco-copy" title="콤보 전체 새로고침" aria-label="콤보 전체 새로고침"><svg viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.5 9a9 9 0 0 1 14.2-3.36L23 10M1 14l5.3 4.36A9 9 0 0 0 20.5 15"></path></svg></button>'
       + '<button type="button" id="btn-copy-coco" class="coco-copy" title="복사" aria-label="복사"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>'
       + '</div></div>'
       + '<textarea id="coco-text" class="coco-ta" readonly></textarea>'
       + '<div class="coco-opts">'
       + '<div class="coco-opt-row"><span class="coco-opt-lbl">구분자</span><input id="inp-coco-sep" class="coco-sep" value=" | " placeholder=" |  / "><label class="coco-opt-right"><input type="checkbox" id="chk-coco-effect" class="coco-opt-chk"><span class="coco-opt-lbl">효과내용 포함</span></label></div>'
       + '<div class="coco-opt-row"><span class="coco-opt-lbl">메모 기호</span>'
       + '<select id="sel-memo-bracket" class="coco-sep" style="width:auto;flex:none"><option value="【】">【】</option><option value="〔〕">〔〕</option><option value="『』">『』</option><option value="⦅⦆">⦅⦆</option><option value="\u201c\u201d">\u201c\u201d</option><option value="none">없음</option><option value="custom">직접입력</option></select>'
       + '<input id="inp-memo-pre" class="coco-sep" style="width:32px;text-align:center;display:none" maxlength="4" placeholder="앞">'
       + '<input id="inp-memo-suf" class="coco-sep" style="width:32px;text-align:center;display:none" maxlength="4" placeholder="뒤">'
       + '</div>'
       + '</div>'
       + '<textarea id="inp-coco-memo" class="coco-memo" placeholder="추가 메모 (콤보 텍스트에 함께 추가됨)"></textarea>'
       + '<div class="coco-save">'
       + '<input id="inp-coco-name" class="coco-name" placeholder="콤보 이름">'
       + '<button type="button" id="btn-save-coco" class="coco-add" title="\ucf64\ubcf4 \ucd94\uac00" aria-label="\ucf64\ubcf4 \ucd94\uac00">+</button>'
       + '</div>'
       + '<div id="calc-coco-list" class="coco-list"></div>'
       + '</div>';
  return html;
}

function setNodeText(id, value) {
  var node = document.getElementById(id);
  if (!node) return;
  var next = String(value == null ? '' : value);
  if (node.textContent !== next) node.textContent = next;
}

function setInputValueIfIdle(id, value) {
  var node = document.getElementById(id);
  if (!node) return;
  if (document.activeElement === node) return;
  var next = String(value == null ? '' : value);
  if (node.value !== next) node.value = next;
}

function setCheckboxValue(id, checked) {
  var node = document.getElementById(id);
  if (!node) return;
  var next = !!checked;
  if (node.checked !== next) node.checked = next;
}

function ensureCalcResultShell(el) {
  if (!el) return;
  if (document.getElementById('calc-dice-formula')) return;
  el.innerHTML = buildCalcResultShellHtml();
}

function buildCalcTagsHtml(featRaw, modRaw, fx, activeExtraRows, rangeVal, targetVal) {
  var html = '';
  if (featRaw !== 'NONE') html += ptag('기능', getFeatLabel(featRaw));
  if (modRaw  !== 'NONE') html += ptag('능력치변동', getFeatLabel(modRaw));
  if (fx.criticalFloor !== 7) html += ptag('크리 하한', '' + fx.criticalFloor);
  if (fx.hpDamage !== 0 || fx.hpDamageDice !== 0) html += ptag('HP대미지', fmtStatWithDiceSigned(fx.hpDamage, fx.hpDamageDice));
  if (fx.ignoreTargetArmor) html += ptag('대상 장갑', '무시');
  if (fx.clearFlight) html += ptag('부가효과', '비행 해제');
  if (fx.sameEngageAllowed) html += ptag('인게이지', '같은 인게이지 가능');
  else if (fx.sameEngageForbidden) html += ptag('인게이지', '같은 인게이지 불가');
  if (fx.dicePenaltyImmune) html += ptag('특수', '다이스 감소 무효');
  if (fx.reactionForbidden) html += ptag('리액션', '불가');
  if (fx.dodgeForbidden) html += ptag('닷지', '불가');
  else if (fx.dodgeNoDiceRoll) html += ptag('닷지', '다이스 롤 불가');
  if (fx.guardForbidden) html += ptag('가드', '불가');
  if (fx.coverForbidden) html += ptag('커버링', '불가');
  else if (fx.coverAsNoGuard) html += ptag('커버링', '가드 미적용');
  activeExtraRows.forEach(function(row) {
    html += ptag('추가수정치', '[' + (row.group || '그룹') + '] ' + getManualFieldLabel(row.field || 'achievement') + ' ' + fmtSigned(row.value));
  });
  html += ptag('사정', rangeVal);
  html += ptag('대상', targetVal);
  return html;
}

function buildSavedComboRowsHtml(cocoState, er) {
  return (cocoState.combos || []).map(function(c, i) {
    var liveText = getSavedComboLiveText(c, er);
    return '<div class="coco-item">'
         + '<div class="coco-item-hd">'
         + '<span class="coco-item-nm">' + esc(c.name || ('콤보 ' + (i + 1))) + '</span>'
         + '<div class="coco-item-actions">'
         + '<button type="button" class="coco-item-btn js-copy-saved" data-idx="' + i + '" title="복사" aria-label="복사"><svg viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg></button>'
         + '<button type="button" class="coco-item-btn danger js-del-saved" data-idx="' + i + '" title="삭제" aria-label="삭제"><svg viewBox="0 0 24 24"><polyline points="3 6 5 6 21 6"></polyline><path d="M8 6V4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v2"></path><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg></button>'
         + '</div></div>'
         + '<textarea class="coco-item-ta js-saved-ta" data-idx="' + i + '">' + esc(liveText) + '</textarea>'
         + '</div>';
  }).join('');
}

function renderCalcEmptyState(el) {
  if (!el) return;
  CALC_VIEW_STATE = null;
  var st = ensureCocoState();
  el.innerHTML = '<div class="calc-empty">이펙트를 체크하거나 기능을 선택하면 결과가 표시됩니다</div>' + buildErosionIgnoreBoxHtml();
  syncCocoOptionCheckboxes(st);
}

function syncCocoOptionCheckboxes(stateLike) {
  var st = stateLike || ensureCocoState();
  setCheckboxValue('chk-coco-effect', !!st.includeEffectText);
  setCheckboxValue('chk-erosion-dice', st.useErosionDiceBonus === false);
  setCheckboxValue('chk-effect-bonus', st.useErosionEffectLvBonus === false);
  setCheckboxValue('chk-item-bonus', st.useItemBonuses === false);
}

function renderCalcResultView(vm) {
  var el = document.getElementById('calc-result');
  if (!el) return;
  ensureCalcResultShell(el);
  setNodeText('calc-dice-formula', vm.formula);
  setNodeText('calc-er-val', vm.totalErText);
  setNodeText('calc-crit-val', vm.crit);
  setNodeText('calc-hit-val', vm.hit);
  setNodeText('calc-dice-val', vm.totalDice);
  setNodeText('calc-atk-val', vm.totalAttack);
  setNodeText('calc-guard-val', vm.totalGuard);
  setNodeText('calc-armor-val', vm.totalArmor);
  setNodeText('calc-init-val', vm.totalInitiative);
  setNodeText('calc-hp-val', vm.hpDeltaText);

  var tags = document.getElementById('calc-tags');
  if (tags) tags.innerHTML = vm.tagsHtml;

  var cocoText = document.getElementById('coco-text');
  if (cocoText && cocoText.value !== vm.cocoText) cocoText.value = vm.cocoText;
  syncCocoOptionCheckboxes(vm);
  setInputValueIfIdle('inp-coco-sep', vm.separatorText);
  setInputValueIfIdle('inp-coco-memo', vm.memoText);
  setInputValueIfIdle('inp-coco-name', vm.draftName);
  setMemoBracketUi(vm.memoPre, vm.memoSuf);

  var cocoList = document.getElementById('calc-coco-list');
  if (cocoList) cocoList.innerHTML = vm.savedRowsHtml || '<div class="coco-opt-lbl">저장된 콤보가 없습니다.</div>';
}

function buildLiveCocoTextFromViewState() {
  var view = CALC_VIEW_STATE;
  if (!view) return '';
  var st = ensureCocoState();
  return buildCocoComboText({
    comboName: (st.draftName || '').trim(),
    effects: view.cocoComboEffects || view.comboEffects,
    includeEffectText: st.includeEffectText,
    memoText: st.memoText,
    memoPre: st.memoPre,
    memoSuf: st.memoSuf,
    separatorText: st.separatorText,
    action: view.actionLabel,
    range: view.rangeVal,
    target: view.targetVal,
    crit: (view.cocoCrit != null) ? view.cocoCrit : view.crit,
    dice: (view.cocoTotalDice != null) ? view.cocoTotalDice : view.totalDice,
    hit: (view.cocoHit != null) ? view.cocoHit : view.hit,
    hitDice: (view.cocoHitDice != null) ? view.cocoHitDice : (view.hitDice || 0),
    attack: (view.cocoTotalAttack != null) ? view.cocoTotalAttack : view.totalAttack,
    attackDice: (view.cocoTotalAttackDice != null) ? view.cocoTotalAttackDice : (view.totalAttackDice || 0),
    guard: (view.cocoTotalGuard != null) ? view.cocoTotalGuard : view.totalGuard,
    guardDice: (view.cocoTotalGuardDice != null) ? view.cocoTotalGuardDice : (view.totalGuardDice || 0),
    erosion: (view.copyTotalEr != null) ? view.copyTotalEr : (view.totalEr || 0)
  });
}

function saveCurrentCocoComboFromView() {
  var view = CALC_VIEW_STATE;
  if (!view) return;
  var st = ensureCocoState();
  var comboText = view.cocoText || buildLiveCocoTextFromViewState() || '';
  var nm = (st.draftName || '').trim() || ('콤보 ' + (st.combos.length + 1));
  st.combos.unshift({
    name: nm,
    text: comboText,
    snapshot: buildSavedComboSnapshot({
      comboName: nm,
      selKeys: (view.comboEffects || []).map(function(e) { return e.key; }),
      featRaw: view.featRaw,
      modRaw: view.modRaw,
      rangeVal: view.rangeVal,
      targetVal: view.targetVal,
      includeEffectText: st.includeEffectText,
      memoText: st.memoText,
      memoPre: st.memoPre,
      memoSuf: st.memoSuf,
      separatorText: st.separatorText,
      efCustom: EF_CUSTOM,
      efCond: EF_COND,
      eqsel: EQSEL,
      customEquip: CUSTOM_EQUIP,
      extraAdj: ensureExtraAdj()
    })
  });
  if (st.combos.length > 40) st.combos = st.combos.slice(0, 40);
  st.draftName = '';
  saveCurrentStateDebounced();
  recalc();
}

function handleCalcOptionToggle(target) {
  var t = target;
  if (!t || !t.id) return false;
  var st = ensureCocoState();
  if (t.id === 'chk-coco-effect') {
    st.includeEffectText = !!t.checked;
  } else if (t.id === 'chk-erosion-dice') {
    st.useErosionDiceBonus = !t.checked;
  } else if (t.id === 'chk-effect-bonus') {
    st.useErosionEffectLvBonus = !t.checked;
  } else if (t.id === 'chk-item-bonus') {
    st.useItemBonuses = !t.checked;
  } else {
    return false;
  }
  saveCurrentStateDebounced();
  recalc();
  return true;
}

function ensureCalcResultEventsBound() {
  var el = document.getElementById('calc-result');
  if (!el || el.__dx3CalcBound) return;
  el.__dx3CalcBound = true;
  el.addEventListener('click', function(e) {
    var refreshBtn = e.target.closest('#btn-refresh-coco');
    if (refreshBtn) {
      e.preventDefault();
      recalc();
      return;
    }
    var copyBtn = e.target.closest('#btn-copy-coco');
    if (copyBtn) {
      e.preventDefault();
      var src = document.getElementById('coco-text');
      copyTextToClipboard(src ? src.value : '', function(ok) {
        if (!ok) return;
        flashDoneState(copyBtn);
      });
      return;
    }
    var diceCopyBtn = e.target.closest('#btn-copy-dice');
    if (diceCopyBtn) {
      e.preventDefault();
      var formula = (CALC_VIEW_STATE && CALC_VIEW_STATE.formula) || ((document.getElementById('calc-dice-formula') || {}).textContent || '');
      copyTextToClipboard(String(formula || ''), function(ok) {
        if (!ok) return;
        flashDoneState(diceCopyBtn);
      });
      return;
    }
    var saveBtn = e.target.closest('#btn-save-coco');
    if (saveBtn) {
      e.preventDefault();
      saveCurrentCocoComboFromView();
      return;
    }
    var savedCopyBtn = e.target.closest('.js-copy-saved');
    if (savedCopyBtn) {
      e.preventDefault();
      var st = ensureCocoState();
      var idx = +savedCopyBtn.getAttribute('data-idx');
      if (isNaN(idx) || !st.combos[idx]) return;
      var ta = el.querySelector('.js-saved-ta[data-idx="' + idx + '"]');
      var liveText = ta ? ta.value : getSavedComboLiveText(st.combos[idx], +((document.getElementById('inp-er') || {}).value || 0));
      copyTextToClipboard(liveText, function(ok) {
        if (!ok) return;
        flashDoneState(savedCopyBtn);
      });
      return;
    }
    var savedDelBtn = e.target.closest('.js-del-saved');
    if (savedDelBtn) {
      e.preventDefault();
      var state = ensureCocoState();
      var delIdx = +savedDelBtn.getAttribute('data-idx');
      if (isNaN(delIdx) || !state.combos[delIdx]) return;
      state.combos.splice(delIdx, 1);
      saveCurrentStateDebounced();
      recalc();
    }
  });
  el.addEventListener('change', function(e) {
    var t = e.target;
    if (!t) return;
    if (handleCalcOptionToggle(t)) {
      return;
    }
    if (t.id === 'sel-memo-bracket') {
      var preEl = document.getElementById('inp-memo-pre');
      var sufEl = document.getElementById('inp-memo-suf');
      var isCustomSel = (t.value === 'custom');
      if (preEl) preEl.style.display = isCustomSel ? '' : 'none';
      if (sufEl) sufEl.style.display = isCustomSel ? '' : 'none';
      var preset = MEMO_BRACKET_PRESETS[t.value];
      if (preset) {
        var bst = ensureCocoState();
        bst.memoPre = preset[0];
        bst.memoSuf = preset[1];
        if (preEl) preEl.value = preset[0];
        if (sufEl) sufEl.value = preset[1];
        saveCurrentStateDebounced();
        recalc();
      } else if (isCustomSel && preEl) {
        preEl.focus();
      }
      return;
    }
    if (t.id === 'inp-coco-memo') {
      recalc();
    }
  });
  el.addEventListener('input', function(e) {
    var t = e.target;
    if (!t) return;
    if (t.id === 'inp-coco-sep') {
      var st = ensureCocoState();
      st.separatorText = t.value || ' | ';
      saveCurrentStateDebounced();
      recalcDebounced();
      return;
    }
    if (t.id === 'inp-memo-pre') {
      var pst = ensureCocoState();
      pst.memoPre = t.value;
      var sel = document.getElementById('sel-memo-bracket');
      if (sel) sel.value = 'custom';
      saveCurrentStateDebounced();
      recalcDebounced();
      return;
    }
    if (t.id === 'inp-memo-suf') {
      var sst = ensureCocoState();
      sst.memoSuf = t.value;
      var sel2 = document.getElementById('sel-memo-bracket');
      if (sel2) sel2.value = 'custom';
      saveCurrentStateDebounced();
      recalcDebounced();
      return;
    }
    if (t.id === 'inp-coco-memo') {
      var memoState = ensureCocoState();
      memoState.memoText = t.value || '';
      saveCurrentStateDebounced();
      return;
    }
    if (t.id === 'inp-coco-name') {
      var nameState = ensureCocoState();
      nameState.draftName = t.value || '';
      saveCurrentStateDebounced();
      return;
    }
    var savedTa = t.closest('.js-saved-ta');
    if (savedTa) {
      var taIdx = +savedTa.getAttribute('data-idx');
      var taSt = ensureCocoState();
      if (!isNaN(taIdx) && taSt.combos[taIdx]) {
        taSt.combos[taIdx].text = savedTa.value;
        taSt.combos[taIdx].snapshot = null;
        saveCurrentStateDebounced();
      }
    }
  });
}

function recalcCore() {
  var el = document.getElementById('calc-result');
  if (!el || !G) return;
  ensureCalcResultEventsBound();

  var er    = +document.getElementById('inp-er').value || 0;
  var dp    = calcDP(er);
  var effLv = calcEL(er);
  refreshAllEffectPreviews();

  var featRaw  = (document.getElementById('sel-feat')    || {}).value || 'NONE';
  var modRaw   = (document.getElementById('sel-statmod') || {}).value || 'NONE';
  var rangeVal = (document.getElementById('sel-range')   || {}).value || '';
  var targetVal= (document.getElementById('sel-target')  || {}).value || '';

  var feat     = parseFeat(featRaw);
  var statIdx  = feat.statIdx;
  var skillVal = feat.skillVal;
  var statVal  = statIdx >= 0 ? G.statValues[STAT_KEYS[statIdx]] : 0;

  var finalStat = statVal;
  if (modRaw !== 'NONE') {
    var mod = parseFeat(modRaw);
    if (mod.statIdx >= 0) finalStat = G.statValues[STAT_KEYS[mod.statIdx]];
  }

  var fx = {
    critical: 0,
    criticalFloor: 7,
    hit: 0,
    achievement: 0,
    dice: 0,
    dicePenaltyImmune: false,
    attack: 0,
    guard: 0,
    armor: 0,
    ignoreTargetArmor: false,
    clearFlight: false,
    sameEngageForbidden: false,
    sameEngageAllowed: false,
    reactionForbidden: false,
    dodgeForbidden: false,
    dodgeNoDiceRoll: false,
    guardForbidden: false,
    coverForbidden: false,
    coverAsNoGuard: false,
    initiative: 0,
    hp: 0,
    hpDamage: 0,
    erosion: 0,
    initiativeFixedZero: false,
    attackDice: 0,
    guardDice: 0,
    armorDice: 0,
    hitDice: 0,
    achievementDice: 0,
    initDice: 0,
    critDice: 0
  };
  var totalEr = 0;
  var dicePos = 0;
  var diceNeg = 0;
  var extraAdj = ensureExtraAdj();

  SEL.forEach(function(s) {
    var ef  = s.ef;

    var effectLv = getAppliedEffectLv(s.key, ef, effLv);
    var parsedBundle = getEffectParsedBundle(s.key, ef, effectLv);
    var txt = parsedBundle.text;
    var autoMods = parsedBundle.autoMods;

    var critDelta = autoMods.critical;
    var hitDelta  = autoMods.hit;
    var diceDelta = autoMods.dice;
    var atkDelta  = autoMods.attack;
    var grdDelta  = autoMods.guard;
    var critFloor = autoMods.criticalFloor;
    if (critFloor !== null) fx.criticalFloor = Math.min(fx.criticalFloor, critFloor);
    if (autoMods.dicePenaltyImmune) fx.dicePenaltyImmune = true;
    if (autoMods.ignoreTargetArmor) fx.ignoreTargetArmor = true;
    if (autoMods.clearFlight) fx.clearFlight = true;
    if (autoMods.sameEngageForbidden) fx.sameEngageForbidden = true;
    if (autoMods.sameEngageAllowed) fx.sameEngageAllowed = true;
    if (autoMods.reactionForbidden) fx.reactionForbidden = true;
    if (autoMods.dodgeForbidden) fx.dodgeForbidden = true;
    if (autoMods.dodgeNoDiceRoll) fx.dodgeNoDiceRoll = true;
    if (autoMods.guardForbidden) fx.guardForbidden = true;
    if (autoMods.coverForbidden) fx.coverForbidden = true;
    if (autoMods.coverAsNoGuard) fx.coverAsNoGuard = true;
    fx.critical += critDelta;
    fx.hit      += hitDelta;
    fx.achievement += (autoMods.achievement || 0);
    if (diceDelta >= 0) dicePos += diceDelta;
    else diceNeg += diceDelta;
    fx.attack   += atkDelta;
    fx.guard    += grdDelta;
    var armorDelta = autoMods.armor;
    fx.armor += armorDelta;
    var initDelta = autoMods.initiative;
    fx.initiative += initDelta;
    fx.hp += autoMods.hp;
    fx.hpDamage += autoMods.hpDamage;
    fx.erosion += (autoMods.erosion || 0);
    if (autoMods.initiativeFixedZero) fx.initiativeFixedZero = true;
    fx.attackDice += (autoMods.attackDice || 0);
    fx.guardDice += (autoMods.guardDice || 0);
    fx.armorDice += (autoMods.armorDice || 0);
    fx.hitDice += (autoMods.hitDice || 0);
    fx.achievementDice += (autoMods.achievementDice || 0);
    fx.initDice += (autoMods.initDice || 0);
    fx.critDice += (autoMods.critDice || 0);

    var manualMods = parsedBundle.manualMods;
    fx.critical += manualMods.critical;
    fx.hit += manualMods.hit;
    fx.achievement += (manualMods.achievement || 0);
    if (manualMods.dice >= 0) dicePos += manualMods.dice;
    else diceNeg += manualMods.dice;
    fx.attack += manualMods.attack;
    fx.guard += manualMods.guard;
    fx.armor += manualMods.armor;
    fx.initiative += manualMods.initiative;
    fx.hp += manualMods.hp;
    fx.hpDamage += manualMods.hpDamage;
    fx.erosion += (manualMods.erosion || 0);
    if (manualMods.criticalFloor !== null) fx.criticalFloor = Math.min(fx.criticalFloor, manualMods.criticalFloor);
    fx.attackDice += (manualMods.attackDice || 0);
    fx.guardDice += (manualMods.guardDice || 0);
    fx.armorDice += (manualMods.armorDice || 0);
    fx.hitDice += (manualMods.hitDice || 0);
    fx.achievementDice += (manualMods.achievementDice || 0);
    fx.initDice += (manualMods.initDice || 0);
    fx.critDice += (manualMods.critDice || 0);
    var mTags = manualMods.tags || {};
    if (mTags.dicePenaltyImmune) fx.dicePenaltyImmune = true;
    if (mTags.initiativeFixedZero) fx.initiativeFixedZero = true;
    if (mTags.reactionForbidden) fx.reactionForbidden = true;
    if (mTags.dodgeForbidden) fx.dodgeForbidden = true;
    if (mTags.dodgeNoDiceRoll) fx.dodgeNoDiceRoll = true;
    if (mTags.guardForbidden) fx.guardForbidden = true;
    if (mTags.coverForbidden) fx.coverForbidden = true;
    if (mTags.coverAsNoGuard) fx.coverAsNoGuard = true;
    if (mTags.ignoreTargetArmor) fx.ignoreTargetArmor = true;

    if (/크리티컬치/i.test(txt) && /-\s*\[\s*LV\s*\]/i.test(txt) && critDelta === 0) {
      fx.critical -= effectLv;
    }
    if (txt.indexOf('\ub2e4\uc774\uc2a4\ub97c +[LV+2]') >= 0 && diceDelta === 0) {
      dicePos += effectLv + 2;
    }

    var erNum = parseFloat(ef.erosion);
    if (!isNaN(erNum)) totalEr += erNum;
  });
  var activeExtraRows = [];
  (extraAdj.groups || []).forEach(function(group, gIdx) {
    if (!group || group.enabled === false) return;
    var groupName = String(group.name || ('추가 수정치 ' + (gIdx + 1)));
    (group.rows || []).forEach(function(row) {
      var extraAdjVal = parseExtraAdjValue((row || {}).value);
      if (isNaN(extraAdjVal) || extraAdjVal === 0) return;
      var extraAdjField = normalizeBonusFieldKey(resolveManualRuleField((row && row.field) ? row.field : 'achievement', (row || {}).value));
      activeExtraRows.push({ group: groupName, field: extraAdjField, value: extraAdjVal });
      if (extraAdjField === 'criticalFloor') {
        fx.criticalFloor = Math.min(fx.criticalFloor, extraAdjVal);
      } else if (extraAdjField === 'dice') {
        if (extraAdjVal >= 0) dicePos += extraAdjVal;
        else diceNeg += extraAdjVal;
      } else if (extraAdjField === 'erosion') {
        fx.erosion += extraAdjVal;
      } else if (Object.prototype.hasOwnProperty.call(fx, extraAdjField)) {
        fx[extraAdjField] += extraAdjVal;
      }
    });
  });
  fx.dice = dicePos + (fx.dicePenaltyImmune ? 0 : diceNeg);
  EFFECT_MODS = {
    critical: fx.critical,
    criticalFloor: fx.criticalFloor,
    hit: fx.hit,
    achievement: fx.achievement,
    dice: fx.dice,
    dicePenaltyImmune: fx.dicePenaltyImmune,
    attack: fx.attack,
    guard: fx.guard,
    armor: fx.armor,
    ignoreTargetArmor: fx.ignoreTargetArmor,
    clearFlight: fx.clearFlight,
    sameEngageForbidden: fx.sameEngageForbidden,
    sameEngageAllowed: fx.sameEngageAllowed,
    reactionForbidden: fx.reactionForbidden,
    dodgeForbidden: fx.dodgeForbidden,
    dodgeNoDiceRoll: fx.dodgeNoDiceRoll,
    guardForbidden: fx.guardForbidden,
    coverForbidden: fx.coverForbidden,
    coverAsNoGuard: fx.coverAsNoGuard,
    initiative: fx.initiative,
    hp: fx.hp,
    hpDamage: fx.hpDamage,
    erosion: fx.erosion,
    achievementDice: fx.achievementDice,
    initiativeFixedZero: fx.initiativeFixedZero,
    attackDice: fx.attackDice,
    guardDice: fx.guardDice,
    armorDice: fx.armorDice,
    hitDice: fx.hitDice,
    initDice: fx.initDice,
    critDice: fx.critDice
  };

  var crit      = Math.max(fx.criticalFloor, 10 + fx.critical);
  var baseDice  = finalStat + dp;
  var totalDice = baseDice + fx.dice;
  var cocoState = ensureCocoState();
  var useItemBonuses = (cocoState.useItemBonuses !== false);

  var equipHit = 0, equipAttack = 0, equipGuard = 0, equipArmor = 0, equipInitiative = 0;
  if (useItemBonuses) {
    (G.weapons || []).forEach(function(w, i){
      if (!isEquipChecked('weapon', getItemStableKey('weapon', w, i))) return;
      var m = +w['\uba85\uc911'];
      if (!isNaN(m)) equipHit += m;
      var atk = +w['\uacf5\uaca9\ub825'];
      if (!isNaN(atk)) equipAttack += atk;
      var grd = +w['\uac00\ub4dc\uce58'];
      if (!isNaN(grd)) equipGuard += grd;
    });
    (G.armors || []).forEach(function(a, i){
      if (!isEquipChecked('armor', getItemStableKey('armor', a, i))) return;
      var arm = +a['\uc7a5\uac11'];
      if (!isNaN(arm)) equipArmor += arm;
      var ini = +a['\ud589\ub3d9'];
      if (!isNaN(ini)) equipInitiative += ini;
    });
    (G.vehicles || []).forEach(function(v, i){
      if (!isEquipChecked('vehicle', getItemStableKey('vehicle', v, i))) return;
      var vatk = +v['\uacf5\uaca9\ub825'];
      if (!isNaN(vatk)) equipAttack += vatk;
      var varm = +v['\uc7a5\uac11'];
      if (!isNaN(varm)) equipArmor += varm;
      var vini = +v['\ud589\ub3d9'];
      if (!isNaN(vini)) equipInitiative += vini;
    });
    (CUSTOM_EQUIP.weapon || []).forEach(function(w) {
      if (w.checked === false) return;
      var m = +w.hit; if (!isNaN(m)) equipHit += m;
      var atk = +w.attack; if (!isNaN(atk)) equipAttack += atk;
      var grd = +w.guard; if (!isNaN(grd)) equipGuard += grd;
    });
    (CUSTOM_EQUIP.armor || []).forEach(function(a) {
      if (a.checked === false) return;
      var arm = +a.armor; if (!isNaN(arm)) equipArmor += arm;
      var ini = +a.initiative; if (!isNaN(ini)) equipInitiative += ini;
    });
    (CUSTOM_EQUIP.vehicle || []).forEach(function(v) {
      if (v.checked === false) return;
      var vatk = +v.attack; if (!isNaN(vatk)) equipAttack += vatk;
      var varm = +v.armor; if (!isNaN(varm)) equipArmor += varm;
      var vini = +v.initiative; if (!isNaN(vini)) equipInitiative += vini;
    });
  }

  var totalHitDice = fx.hitDice + fx.achievementDice;
  var hitTotal = skillVal + equipHit + fx.hit + fx.achievement;
  var totalAttack = equipAttack + fx.attack;
  var totalGuard = equipGuard + fx.guard;
  var totalArmor = equipArmor + fx.armor;
  var totalInitiative = fx.initiativeFixedZero ? 0 : (toNum(G.initiative) + equipInitiative + fx.initiative);
  var totalInitDice = fx.initiativeFixedZero ? 0 : fx.initDice;
  var hpDelta = fx.hp;
  var totalErosion = totalEr + fx.erosion;

  if (SEL.length === 0 && activeExtraRows.length === 0) {
    renderCalcEmptyState(el);
    return;
  }

  var comboEffects = SEL.map(function(s) {
    var ef = s.ef || {};
    var adjLv = getAppliedEffectLv(s.key, ef, effLv);
    return {
      key: s.key,
      name: String(ef.name || ''),
      lvText: (ef.lv !== '' && ef.lv !== null) ? String(adjLv) : '',
      effectText: String(ef.effect || '')
    };
  });
  var copyOutput = buildCopyOutputFromEffects(SEL, effLv, (cocoState.useErosionEffectLvBonus !== false), extraAdj);
  var copyTotalErosion = totalEr + (copyOutput.mods.erosion || 0);
  var actionLabel = (featRaw !== 'NONE') ? getFeatLabel(featRaw) : '\u2015';
  var copyMetrics = buildCopyOutputMetrics({
    outputMods: copyOutput.mods,
    finalStat: finalStat,
    useErosionDiceBonus: (cocoState.useErosionDiceBonus !== false),
    dp: dp,
    skillVal: skillVal,
    equipHit: equipHit,
    equipAttack: equipAttack,
    equipGuard: equipGuard,
    hasAction: featRaw !== 'NONE'
  });

  var cocoComboEffects = copyOutput.effects;

  var cocoText = buildCocoComboText({
    comboName: (cocoState.draftName || '').trim(),
    effects: cocoComboEffects,
    includeEffectText: cocoState.includeEffectText,
    memoText: cocoState.memoText,
    memoPre: cocoState.memoPre,
    memoSuf: cocoState.memoSuf,
    separatorText: cocoState.separatorText,
    action: actionLabel,
    range: rangeVal,
    target: targetVal,
    crit: copyMetrics.crit,
    dice: copyMetrics.totalDice,
    hit: copyMetrics.hit,
    hitDice: copyMetrics.totalHitDice,
    attack: copyMetrics.attack,
    attackDice: copyMetrics.attackDice,
    guard: copyMetrics.guard,
    guardDice: copyMetrics.guardDice,
    erosion: copyTotalErosion || 0
  });
  var tagsHtml = buildCalcTagsHtml(featRaw, modRaw, fx, activeExtraRows, rangeVal, targetVal);
  var savedRows = buildSavedComboRowsHtml(cocoState, er);

  CALC_VIEW_STATE = {
    formula: copyMetrics.formula,
    featRaw: featRaw,
    modRaw: modRaw,
    rangeVal: rangeVal,
    targetVal: targetVal,
    comboEffects: comboEffects,
    cocoComboEffects: cocoComboEffects,
    actionLabel: actionLabel,
    crit: crit,
    cocoCrit: copyMetrics.crit,
    totalDice: totalDice,
    cocoTotalDice: copyMetrics.totalDice,
    hit: hitTotal,
    cocoHit: copyMetrics.hit,
    hitDice: totalHitDice,
    cocoHitDice: copyMetrics.totalHitDice,
    totalAttack: totalAttack,
    totalAttackDice: fx.attackDice,
    cocoTotalAttack: copyMetrics.attack,
    cocoTotalAttackDice: copyMetrics.attackDice,
    totalGuard: totalGuard,
    totalGuardDice: fx.guardDice,
    cocoTotalGuard: copyMetrics.guard,
    cocoTotalGuardDice: copyMetrics.guardDice,
    totalEr: totalErosion || 0,
    copyTotalEr: copyTotalErosion || 0,
    cocoText: cocoText,
    useItemBonuses: useItemBonuses
  };

  renderCalcResultView({
    formula: copyMetrics.formula,
    totalErText: fmtSigned(totalErosion),
    crit: '' + crit,
    hit: fmtStatWithDice(hitTotal, totalHitDice),
    totalDice: '' + totalDice,
    totalAttack: fmtStatWithDice(totalAttack, fx.attackDice),
    totalGuard: fmtStatWithDice(totalGuard, fx.guardDice),
    totalArmor: fmtStatWithDice(totalArmor, fx.armorDice),
    totalInitiative: fmtStatWithDice(totalInitiative, totalInitDice),
    hpDeltaText: fmtStatWithDiceSigned(hpDelta, fx.hpDice),
    tagsHtml: tagsHtml,
    cocoText: cocoText,
    includeEffectText: cocoState.includeEffectText,
    useErosionDiceBonus: cocoState.useErosionDiceBonus !== false,
    useErosionEffectLvBonus: cocoState.useErosionEffectLvBonus !== false,
    useItemBonuses: useItemBonuses,
    separatorText: cocoState.separatorText || ' | ',
    memoText: cocoState.memoText || '',
    memoPre: cocoState.memoPre,
    memoSuf: cocoState.memoSuf,
    draftName: cocoState.draftName || '',
    savedRowsHtml: savedRows
  });
}

var MEMO_BRACKET_PRESETS = {
  '\u3010\u3011': ['\u3010', '\u3011'],
  '\u3014\u3015': ['\u3014', '\u3015'],
  '\u300e\u300f': ['\u300e', '\u300f'],
  '\uff5f\uff60': ['\uff5f', '\uff60'],
  '\u201c\u201d': ['\u201c', '\u201d'],
  'none': ['', '']
};

function setMemoBracketUi(pre, suf) {
  var selEl = document.getElementById('sel-memo-bracket');
  var preEl = document.getElementById('inp-memo-pre');
  var sufEl = document.getElementById('inp-memo-suf');
  if (!selEl || !preEl || !sufEl) return;
  var isCustom = false;
  if (document.activeElement === preEl || document.activeElement === sufEl) {
    isCustom = true;
  } else {
    if (preEl.value !== pre) preEl.value = pre;
    if (sufEl.value !== suf) sufEl.value = suf;
    var found = false;
    Object.keys(MEMO_BRACKET_PRESETS).forEach(function(k) {
      if (k !== 'custom' && MEMO_BRACKET_PRESETS[k][0] === pre && MEMO_BRACKET_PRESETS[k][1] === suf) {
        if (selEl.value !== k) selEl.value = k;
        found = true;
      }
    });
    if (!found) { selEl.value = 'custom'; isCustom = true; }
    else isCustom = (selEl.value === 'custom');
  }
  var show = isCustom ? '' : 'none';
  preEl.style.display = show;
  sufEl.style.display = show;
}

function ptag(l, v) {
  return '<div class="ptag">' + l + ': <span>' + esc(v) + '</span></div>';
}
function esc(s) {
  return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
</script>
</body>
</html>

4. 스프레드 시트로 돌아가 새로고침을 하면, 메뉴 끝에 DX3 계산기가 생깁니다.

이펙트 계산기 열기를 누르면 구글에서 확인하지 않은 앱이 나올겁니다. (공식 구글 앱이 아니라서 어쩔 수 없습니다...ㅠ)

고급을 눌러주시고 DX3 계산기(으)로 이동(안전하지 않음)을 눌러주세요.

해당 시트에서는 한번만 하면 됩니다.

5. 그러고 나서는 오른쪽 사이드 바에 계산기가 뜹니다.

시트명에 PC라고 적힌 시트들이 콤보박스 목록에 생깁니다.

(혹은 PC를 포함하는 단어면 뭐든 좋습니다. PC1, 이름(PC),적(NPC)등...)

콤보박스 오른쪽에 있는 마스터 보기로 바꾸면 처음에는 조금 로딩이 걸리지만,

시트 이름으로 버튼이 생기면서 누르면 바로바로 다른 시트의 계산기를 확인 할 수 있습니다.

이 이후로는 해당 스프레드 시트를 복사해서 쓰시면 설치는 하지 않으셔도 됩니다.

인증 확인은 사용자마다 다시 해야 합니다...ㅠ


[사용 방법]

사이드바 상단에 있는 ? 버튼을 누르면 나옵니다!

[주의]

- 해당 시트의 데이터를 불러와서 인식하기때문에 시트를 작성하시고 켜주세요.

- 계산기에 적은 콤보나 이펙트 내용 수정은 해당 컴퓨터에서만 저장이 됩니다.

다른 컴퓨터에서도 동일한 내용으로 쓰고 싶으실 때는 내보내기로 저장해주시고, 불러오기로 업로드해주세요!

해당 시트의 어딘가의 셀에 저장됩니다.

- 자동 인식 기능은 제 데이터를 기반으로 만들어서, 자동 인식이 안되는 곳도 분명 있습니다.

그럴때는 수동 추가쪽을 이용해주세요...!

- 자동 인식 개선을 위한 개인적인 수정은 괜찮지만, 재배포는 하지 말아주세요.

- 덥크 뉴비가 만든거라 아직 테스트중입니다!

문제가 일어날 시 이쪽으로 문의해주세요.

https://forms.gle/CoFJyQaJysfNZ5Z96