/* ---------------------------------------------------------------------------
   Pre-spawn interface behaviour.

   The client Lua owns the truth about stages; this file renders whatever it is
   told and asks for changes. Validation here is for live feedback only - the
   server re-checks everything and its answer is the one that counts.
   --------------------------------------------------------------------------- */

'use strict';

const $  = (id) => document.getElementById(id);
const $$ = (sel, root) => Array.from((root || document).querySelectorAll(sel));

async function post(name, data) {
  try {
    const res = await fetch(`https://rp_ui/${name}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json; charset=UTF-8' },
      body: JSON.stringify(data || {}),
    });
    return await res.json();
  } catch (err) {
    return { ok: false, error: 'The game stopped responding. Try again.' };
  }
}

/* Fire-and-forget for high-frequency edits (slider drags). */
function poke(name, data) {
  fetch(`https://rp_ui/${name}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json; charset=UTF-8' },
    body: JSON.stringify(data || {}),
  }).catch(() => {});
}

/* =========================================================================
   screen manager
   ========================================================================= */

const SCREENS = ['auth', 'characters', 'creator', 'spawn'];
const CHIP_FOR = { auth: 'auth', characters: 'characters', creator: 'characters', spawn: 'spawn' };
const CHIP_ORDER = ['auth', 'characters', 'spawn'];

let current = null;

function showScreen(name) {
  SCREENS.forEach((s) => {
    const el = $(`screen-${s}`);
    if (!el) return;
    if (s === name) {
      el.hidden = false;
      /* restart the entrance animation */
      el.classList.remove('is-on');
      void el.offsetWidth;
      el.classList.add('is-on');
    } else {
      el.hidden = true;
      el.classList.remove('is-on');
    }
  });
  current = name;
  paintChips(CHIP_FOR[name]);
  document.body.classList.remove('hidden');
}

function paintChips(activeKey) {
  const idx = CHIP_ORDER.indexOf(activeKey);
  $$('.stagechip').forEach((chip) => {
    const i = CHIP_ORDER.indexOf(chip.dataset.stage);
    chip.classList.toggle('on', i === idx);
    chip.classList.toggle('done', i < idx);
  });
}

/* The stamp is the only loud thing in the interface, so it only ever marks a
   real change of state: the file opened, the application approved, the
   placement issued. The paper on the desk takes the hit with it. */
function stamp(text) {
  const el = $('stamp');
  $('stampText').textContent = text;
  el.classList.remove('show');
  document.body.classList.remove('struck');
  void el.offsetWidth;
  el.classList.add('show');
  setTimeout(() => document.body.classList.add('struck'), 170);
  setTimeout(() => document.body.classList.remove('struck'), 600);
}

function busy(btn, on) {
  btn.classList.toggle('busy', on);
  btn.disabled = on;
}

function showError(el, message) {
  if (!message) {
    el.hidden = true;
    return;
  }
  el.textContent = message;
  el.hidden = false;
  /* replay the shake even if the message is unchanged */
  el.style.animation = 'none';
  void el.offsetWidth;
  el.style.animation = '';
}

/* =========================================================================
   validation (mirrors shared/util.lua; the server still decides)
   ========================================================================= */

const check = {
  username(v) {
    v = (v || '').trim();
    if (!v) return [null, 'Pick a username.'];
    if (v.length < 3) return [false, 'A bit longer - at least 3 characters.'];
    if (v.length > 20) return [false, 'Too long - 20 characters at most.'];
    if (!/^[A-Za-z0-9_]+$/.test(v)) return [false, 'Letters, numbers and underscore only.'];
    return [true, 'That will do.'];
  },
  password(v) {
    v = v || '';
    if (!v) return [null, 'At least 8 characters, with a letter and a number.'];
    if (v.length < 8) return [false, `${8 - v.length} more character${8 - v.length === 1 ? '' : 's'} needed.`];
    if (!/[A-Za-z]/.test(v)) return [false, 'Add at least one letter.'];
    if (!/[0-9]/.test(v)) return [false, 'Add at least one number.'];
    return [true, 'Strong enough.'];
  },
  name(v) {
    v = (v || '').trim();
    if (!v) return [null, 'Required.'];
    if (v.length < 2) return [false, 'Too short.'];
    if (v.length > 20) return [false, 'Too long.'];
    if (!/^[A-Za-z][A-Za-z'-]*$/.test(v)) return [false, "Letters, ' and - only."];
    return [true, ''];
  },
  dob(v) {
    v = (v || '').trim();
    const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(v);
    if (!v) return [null, 'YYYY-MM-DD'];
    if (!m) return [false, 'Use YYYY-MM-DD.'];
    const y = +m[1], mo = +m[2], d = +m[3];
    if (mo < 1 || mo > 12) return [false, 'Month must be 01-12.'];
    const days = [31, (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0 ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    if (d < 1 || d > days[mo - 1]) return [false, 'That day does not exist.'];
    if (y < 1930 || y > 2010) return [false, 'Year must be between 1930 and 2010.'];
    return [true, ''];
  },
};

/* Paints a field's state. `state` is true / false / null (neutral). */
function mark(fieldName, state, message) {
  const field = document.querySelector(`.field[data-field="${fieldName}"]`);
  if (!field) return;
  field.classList.toggle('good', state === true);
  field.classList.toggle('bad', state === false);
  const hint = field.querySelector('.hint');
  if (hint && message !== undefined && message !== null) hint.textContent = message;
}

/* =========================================================================
   01 / auth
   ========================================================================= */

const Auth = {
  mode: 'register',

  init() {
    $('switchMode').addEventListener('click', () => this.toggle());
    $('authForm').addEventListener('submit', (e) => { e.preventDefault(); this.submit(); });

    $('revealPw').addEventListener('click', () => {
      const input = $('password');
      const show = input.type === 'password';
      input.type = show ? 'text' : 'password';
      $('revealPw').textContent = show ? 'Hide' : 'Show';
      $('revealPw').setAttribute('aria-label', show ? 'Hide password' : 'Show password');
    });

    $('username').addEventListener('input', () => {
      const [ok, msg] = check.username($('username').value);
      mark('username', ok, msg);
    });
    $('password').addEventListener('input', () => {
      const [ok, msg] = check.password($('password').value);
      mark('password', ok, msg);
      if (this.mode === 'register' && $('confirm').value) this.checkConfirm();
    });
    $('confirm').addEventListener('input', () => this.checkConfirm());
  },

  checkConfirm() {
    const a = $('password').value;
    const b = $('confirm').value;
    if (!b) return mark('confirm', null, 'Both entries must match.');
    mark('confirm', a === b, a === b ? 'They match.' : 'These do not match yet.');
  },

  toggle() {
    this.mode = this.mode === 'register' ? 'login' : 'register';
    const reg = this.mode === 'register';

    $('authTitle').textContent = reg ? 'Open a file' : 'Return to your file';
    $('authSub').textContent = reg
      ? 'Everything you do in this city is recorded against this file. Choose a name you will remember and a password you will not share.'
      : 'Sign in and the city picks up where you left it.';
    $('authSubmit').querySelector('.btn-label').textContent = reg ? 'Open the file' : 'Sign in';
    $('switchText').textContent = reg ? 'Already have a file?' : 'No file yet?';
    $('switchMode').textContent = reg ? 'Sign in instead' : 'Open one now';

    $('confirmWrap').classList.toggle('open', reg);
    $('confirmWrap').setAttribute('aria-hidden', String(!reg));
    $('password').setAttribute('autocomplete', reg ? 'new-password' : 'current-password');

    showError($('authError'), null);
    if (!reg) mark('confirm', null, 'Both entries must match.');
  },

  async submit() {
    const username = $('username').value.trim();
    const password = $('password').value;
    const reg = this.mode === 'register';

    const [uOk, uMsg] = check.username(username);
    if (uOk !== true) { mark('username', false, uMsg); $('username').focus(); return; }

    if (reg) {
      const [pOk, pMsg] = check.password(password);
      if (pOk !== true) { mark('password', false, pMsg); $('password').focus(); return; }
      if ($('confirm').value !== password) {
        mark('confirm', false, 'These do not match yet.');
        $('confirm').focus();
        return;
      }
    } else if (!password) {
      mark('password', false, 'Enter your password.');
      $('password').focus();
      return;
    }

    const btn = $('authSubmit');
    busy(btn, true);
    showError($('authError'), null);

    const res = await post('auth:submit', { mode: this.mode, username, password });
    busy(btn, false);

    if (!res.ok) return showError($('authError'), res.error || 'That did not work.');

    if (reg) stamp('Filed');
    Characters.load(res.characters || [], res.maxChars || 3);
    showScreen('characters');
  },
};

/* =========================================================================
   02 / characters
   ========================================================================= */

const Characters = {
  list: [],
  max: 3,
  selected: null,

  init() {
    $('charEnter').addEventListener('click', () => this.enter());
    $('charDelete').addEventListener('click', () => this.remove());
  },

  load(list, max) {
    this.list = list || [];
    this.max = max || 3;
    this.selected = null;
    this.render();
  },

  render() {
    const ul = $('charList');
    ul.innerHTML = '';
    $('charCount').textContent = `${this.list.length} / ${this.max}`;

    this.list.forEach((c, i) => {
      const li = document.createElement('li');
      li.className = 'charcard' + (this.selected === c.id ? ' on' : '');
      li.tabIndex = 0;
      li.innerHTML = `
        <span class="charslot">0${i + 1}</span>
        <span>
          <span class="charname"></span>
          <span class="charmeta"></span>
        </span>
        <span class="charmoney"></span>`;
      li.querySelector('.charname').textContent = `${c.firstName} ${c.lastName}`;
      li.querySelector('.charmeta').textContent = this.meta(c);
      li.querySelector('.charmoney').textContent = `$${(c.cash + c.bank).toLocaleString('en-US')}`;

      const pick = () => this.select(c.id);
      li.addEventListener('click', pick);
      li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
      ul.appendChild(li);
    });

    if (this.list.length < this.max) {
      const li = document.createElement('li');
      li.className = 'charcard empty';
      li.tabIndex = 0;
      li.innerHTML = `<span class="charslot">+</span><span class="charname">Create a new identity</span>`;
      const go = () => Creator.open();
      li.addEventListener('click', go);
      li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
      ul.appendChild(li);
    }

    $('charEnter').disabled = this.selected === null;
    $('charDelete').disabled = this.selected === null;
  },

  meta(c) {
    const hours = Math.floor((c.playtime || 0) / 3600);
    const played = hours > 0 ? `${hours}h played` : 'never played';
    const job = c.job && c.job !== 'unemployed' ? c.job : 'no job';
    return `${c.dob} · ${job} · ${played}`;
  },

  select(id) {
    this.selected = id;
    this.render();
    showError($('charError'), null);
    poke('char:preview', { id });
  },

  async enter() {
    if (this.selected === null) return;
    const btn = $('charEnter');
    busy(btn, true);
    const res = await post('char:select', { id: this.selected });
    busy(btn, false);
    if (!res.ok) return showError($('charError'), res.error || 'Could not load that character.');
    Spawn.load(res.spawns || [], res.lastPosition, res.character);
    showScreen('spawn');
  },

  async remove() {
    if (this.selected === null) return;
    const c = this.list.find((x) => x.id === this.selected);
    if (!c) return;
    const btn = $('charDelete');

    /* two-step, in place: the button becomes the confirmation */
    if (btn.dataset.armed !== '1') {
      btn.dataset.armed = '1';
      btn.textContent = `Delete ${c.firstName}?`;
      setTimeout(() => {
        if (btn.dataset.armed === '1') { btn.dataset.armed = '0'; btn.textContent = 'Delete'; }
      }, 4000);
      return;
    }

    btn.dataset.armed = '0';
    btn.textContent = 'Delete';
    busy(btn, true);
    const res = await post('char:delete', { id: this.selected });
    busy(btn, false);
    if (!res.ok) return showError($('charError'), res.error || 'Could not delete that character.');
    this.load(res.characters || [], this.max);
  },
};

/* =========================================================================
   03 / creator
   ========================================================================= */

const SECTIONS = ['heritage', 'face', 'hair', 'body', 'clothing', 'identity'];

const Creator = {
  appearance: null,
  limits: null,
  schema: null,
  palettes: null,
  section: 'heritage',

  init() {
    $$('.tab').forEach((t) => t.addEventListener('click', () => this.go(t.dataset.section)));
    $('creatorCancel').addEventListener('click', () => this.cancel());
    $('creatorPrev').addEventListener('click', () => this.step(-1));
    $('creatorNext').addEventListener('click', () => this.step(1));
    this.initDrag();
  },

  async open() {
    const res = await post('creator:start', { gender: 'm' });
    if (!res.ok) return showError($('charError'), 'Could not start the editor.');
    this.absorb(res);
    this.section = 'heritage';
    this.paintTabs();
    this.renderAll();
    showScreen('creator');
    poke('creator:frame', { section: 'heritage' });
  },

  absorb(res) {
    this.appearance = res.appearance;
    this.limits = res.limits;
    this.schema = res.schema || this.schema;
    this.palettes = res.palettes || this.palettes;
  },

  go(section) {
    this.section = section;
    this.paintTabs();
    poke('creator:frame', { section });
  },

  paintTabs() {
    $$('.tab').forEach((t) => t.classList.toggle('is-on', t.dataset.section === this.section));
    $$('.pane').forEach((p) => p.classList.toggle('is-on', p.dataset.section === this.section));
    const i = SECTIONS.indexOf(this.section);
    $('creatorPrev').disabled = i === 0;
    $('creatorNext').querySelector('.btn-label').textContent =
      i === SECTIONS.length - 1 ? 'Submit application' : 'Next';
  },

  step(dir) {
    const i = SECTIONS.indexOf(this.section);
    if (dir > 0 && i === SECTIONS.length - 1) return this.submit();
    const next = SECTIONS[Math.min(SECTIONS.length - 1, Math.max(0, i + dir))];
    this.go(next);
  },

  /* --- control builders ------------------------------------------------- */

  slider(label, value, min, max, step, format, onInput) {
    const wrap = document.createElement('div');
    wrap.className = 'ctrl';
    wrap.innerHTML = `
      <div class="ctrl-head">
        <span class="ctrl-label"></span>
        <span class="ctrl-val"></span>
      </div>
      <input class="slider" type="range">`;
    wrap.querySelector('.ctrl-label').textContent = label;
    const val = wrap.querySelector('.ctrl-val');
    const input = wrap.querySelector('input');
    input.min = min; input.max = max; input.step = step; input.value = value;
    val.textContent = format(value);
    input.addEventListener('input', () => {
      const v = Number(input.value);
      val.textContent = format(v);
      onInput(v);
    });
    return wrap;
  },

  stepper(label, value, min, max, onChange) {
    const wrap = document.createElement('div');
    wrap.className = 'ctrl';
    wrap.innerHTML = `
      <div class="ctrl-head">
        <span class="ctrl-label"></span>
        <span class="ctrl-val"></span>
      </div>
      <div class="stepper">
        <button type="button" aria-label="Previous">&minus;</button>
        <span class="stepval"></span>
        <button type="button" aria-label="Next">+</button>
      </div>`;
    wrap.querySelector('.ctrl-label').textContent = label;
    const range = wrap.querySelector('.ctrl-val');
    const out = wrap.querySelector('.stepval');
    let v = value;
    let lo = min;
    let hi = max;

    const paint = () => {
      out.textContent = v < 0 ? 'None' : String(v);
      range.textContent = hi <= lo ? '—' : `${Math.max(0, lo)}–${hi}`;
    };
    const set = (next) => {
      if (next < lo) next = hi;      // wraps, so browsing a long list is quick
      if (next > hi) next = lo;
      v = next;
      paint();
      onChange(v);
    };
    /* Changing a garment changes how many variants it has; the variant control
       is re-bounded in place rather than rebuilt. */
    const setBounds = (newMax, newValue) => {
      hi = typeof newMax === 'number' ? newMax : hi;
      if (typeof newValue === 'number') v = newValue;
      if (v > hi) v = lo;
      paint();
    };
    wrap.querySelectorAll('button')[0].addEventListener('click', () => set(v - 1));
    wrap.querySelectorAll('button')[1].addEventListener('click', () => set(v + 1));
    paint();
    return { el: wrap, set, setBounds, get value() { return v; } };
  },

  swatches(label, palette, value, onPick) {
    const wrap = document.createElement('div');
    /* A colour chart is 16 columns wide, so it always takes the full form. */
    wrap.className = 'ctrl span2';
    wrap.innerHTML = `
      <div class="ctrl-head"><span class="ctrl-label"></span><span class="ctrl-val"></span></div>
      <div class="swatches"></div>`;
    wrap.querySelector('.ctrl-label').textContent = label;
    const val = wrap.querySelector('.ctrl-val');
    const row = wrap.querySelector('.swatches');
    val.textContent = String(value);

    (palette || []).forEach((c, i) => {
      const b = document.createElement('button');
      b.type = 'button';
      b.className = 'swatch' + (i === value ? ' on' : '');
      b.style.background = `rgb(${c.r}, ${c.g}, ${c.b})`;
      b.title = `Colour ${i}`;
      b.addEventListener('click', () => {
        row.querySelectorAll('.swatch').forEach((s) => s.classList.remove('on'));
        b.classList.add('on');
        val.textContent = String(i);
        onPick(i);
      });
      row.appendChild(b);
    });
    return wrap;
  },

  /* --- panes ------------------------------------------------------------ */

  renderAll() {
    this.renderHeritage();
    this.renderFace();
    this.renderHair();
    this.renderBody();
    this.renderClothing();
    this.renderIdentity();
  },

  pane(section) {
    const el = document.querySelector(`.pane[data-section="${section}"]`);
    el.innerHTML = '';
    return el;
  },

  renderHeritage() {
    const p = this.pane('heritage');
    const a = this.appearance;

    const seg = document.createElement('div');
    seg.className = 'seg';
    seg.innerHTML = `
      <button type="button" data-g="m">Male</button>
      <button type="button" data-g="f">Female</button>`;
    seg.querySelectorAll('button').forEach((b) => {
      b.classList.toggle('on', b.dataset.g === a.model);
      b.addEventListener('click', async () => {
        if (b.dataset.g === this.appearance.model) return;
        const res = await post('creator:gender', { gender: b.dataset.g });
        if (!res.ok) return;
        this.absorb(res);
        this.renderAll();
        this.paintTabs();
      });
    });
    p.appendChild(seg);

    p.appendChild(this.slider('Father', a.parents.father, 0, 45, 1, (v) => `#${v}`, (v) => {
      a.parents.father = v;
      poke('creator:parent', { key: 'father', value: v });
    }));
    p.appendChild(this.slider('Mother', a.parents.mother, 0, 45, 1, (v) => `#${v}`, (v) => {
      a.parents.mother = v;
      poke('creator:parent', { key: 'mother', value: v });
    }));
    p.appendChild(this.slider('Resemblance', a.parents.shapeMix, 0, 1, 0.01,
      (v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`,
      (v) => { a.parents.shapeMix = v; poke('creator:parent', { key: 'shapeMix', value: v }); }));
    p.appendChild(this.slider('Skin tone', a.parents.skinMix, 0, 1, 0.01,
      (v) => `${Math.round((1 - v) * 100)}% father / ${Math.round(v * 100)}% mother`,
      (v) => { a.parents.skinMix = v; poke('creator:parent', { key: 'skinMix', value: v }); }));
  },

  renderFace() {
    const p = this.pane('face');
    const a = this.appearance;

    (this.schema.features || []).forEach((f) => {
      const key = String(f.id);
      const v = a.features[key] || 0;
      p.appendChild(this.slider(f.label, v, -1, 1, 0.01,
        (x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`),
        (x) => { a.features[key] = x; poke('creator:feature', { id: f.id, value: x }); }));
    });

    p.appendChild(this.slider('Eye colour', a.eyeColour, 0, 31, 1, (v) => `#${v}`, (v) => {
      a.eyeColour = v;
      poke('creator:eyes', { value: v });
    }));

    this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint !== 'hair'));
  },

  renderHair() {
    const p = this.pane('hair');
    const a = this.appearance;
    const maxHair = (this.limits && this.limits.hair) || 0;

    const st = this.stepper('Hair style', a.hair.style, 0, maxHair, (v) => {
      a.hair.style = v;
      poke('creator:hair', { style: v });
    });
    p.appendChild(st.el);

    p.appendChild(this.swatches('Hair colour', this.palettes && this.palettes.hair, a.hair.colour, (i) => {
      a.hair.colour = i;
      poke('creator:hair', { colour: i });
    }));
    p.appendChild(this.swatches('Highlights', this.palettes && this.palettes.hair, a.hair.highlight, (i) => {
      a.hair.highlight = i;
      poke('creator:hair', { highlight: i });
    }));

    /* Only the hair-tinted overlays belong here; skin and make-up sit with
       the face, which keeps either pane from becoming a wall of controls. */
    this.overlayControls(p, (this.schema.overlays || []).filter((o) => o.tint === 'hair'));
  },

  overlayControls(p, list) {
    const a = this.appearance;
    list.forEach((o) => {
      const ov = a.overlays[o.key] || { index: -1, opacity: 1, colour: 0 };
      const s = this.stepper(o.label, ov.index, -1, o.max, (v) => {
        ov.index = v;
        poke('creator:overlay', { key: o.key, index: v });
      });
      p.appendChild(s.el);

      p.appendChild(this.slider(`${o.label} strength`, ov.opacity, 0, 1, 0.05,
        (v) => `${Math.round(v * 100)}%`,
        (v) => { ov.opacity = v; poke('creator:overlay', { key: o.key, opacity: v }); }));

      if (o.tint) {
        const pal = o.tint === 'hair' ? this.palettes.hair : this.palettes.makeup;
        p.appendChild(this.swatches(`${o.label} colour`, pal, ov.colour, (i) => {
          ov.colour = i;
          poke('creator:overlay', { key: o.key, colour: i });
        }));
      }
    });
  },

  renderBody() {
    const p = this.pane('body');
    const a = this.appearance;
    const note = document.createElement('p');
    note.className = 'sub';
    note.textContent = 'Build is carried by the heritage blend and these two proportions.';
    p.appendChild(note);

    ['13', '19'].forEach((id) => {
      const f = (this.schema.features || []).find((x) => String(x.id) === id);
      if (!f) return;
      p.appendChild(this.slider(f.label, a.features[id] || 0, -1, 1, 0.01,
        (x) => (x === 0 ? 'neutral' : `${x > 0 ? '+' : ''}${x.toFixed(2)}`),
        (x) => { a.features[id] = x; poke('creator:feature', { id: Number(id), value: x }); }));
    });
  },

  renderClothing() {
    const p = this.pane('clothing');
    const a = this.appearance;

    (this.schema.components || []).forEach((c) => {
      const cc = a.components[c.key] || { drawable: 0, texture: 0 };
      const lim = (this.limits.components && this.limits.components[c.key]) || { drawable: 0, texture: 0 };

      const tex = this.stepper(`${c.label} variant`, cc.texture, 0, lim.texture, (v) => {
        cc.texture = v;
        poke('creator:component', { key: c.key, texture: v });
      });

      const draw = this.stepper(c.label, cc.drawable, 0, lim.drawable, async (v) => {
        cc.drawable = v;
        const res = await post('creator:component', { key: c.key, drawable: v });
        if (res && res.ok) {
          cc.texture = res.texture;
          tex.setBounds(res.maxTexture, res.texture);
        }
      });

      p.appendChild(draw.el);
      p.appendChild(tex.el);
    });

    (this.schema.props || []).forEach((pr) => {
      const pp = a.props[pr.key] || { drawable: -1, texture: 0 };
      const lim = (this.limits.props && this.limits.props[pr.key]) || { drawable: -1, texture: 0 };
      const s = this.stepper(pr.label, pp.drawable, -1, Math.max(-1, lim.drawable), (v) => {
        pp.drawable = v;
        poke('creator:prop', { key: pr.key, drawable: v });
      });
      p.appendChild(s.el);
    });
  },

  renderIdentity() {
    const p = this.pane('identity');
    p.innerHTML = `
      <div class="field" data-field="firstName">
        <label for="firstName">First name</label>
        <input id="firstName" type="text" maxlength="20" spellcheck="false" placeholder="Danielle">
        <p class="hint">The name people will call you.</p>
      </div>
      <div class="field" data-field="lastName">
        <label for="lastName">Last name</label>
        <input id="lastName" type="text" maxlength="20" spellcheck="false" placeholder="Larue">
        <p class="hint">Family name. Must be unique on this server.</p>
      </div>
      <div class="field" data-field="dob">
        <label for="dob">Date of birth</label>
        <input id="dob" type="text" maxlength="10" spellcheck="false" placeholder="1994-07-12">
        <p class="hint">YYYY-MM-DD</p>
      </div>
      <div class="field" data-field="backstory">
        <label for="backstory">Backstory</label>
        <textarea id="backstory" maxlength="2000"
          placeholder="Where were they before Los Santos, and why did they leave?"></textarea>
        <p class="counter"><span id="storyCount">0</span> / 2000</p>
      </div>`;

    $('firstName').addEventListener('input', () => {
      const [ok, msg] = check.name($('firstName').value);
      mark('firstName', ok, msg || 'The name people will call you.');
    });
    $('lastName').addEventListener('input', () => {
      const [ok, msg] = check.name($('lastName').value);
      mark('lastName', ok, msg || 'Family name. Must be unique on this server.');
    });
    $('dob').addEventListener('input', () => {
      const [ok, msg] = check.dob($('dob').value);
      mark('dob', ok, msg || 'YYYY-MM-DD');
    });
    $('backstory').addEventListener('input', () => {
      $('storyCount').textContent = String($('backstory').value.length);
    });
  },

  /* --- drag to turn ----------------------------------------------------- */

  initDrag() {
    const tt = $('turntable');
    let dragging = false;
    let lastX = 0;
    let pending = 0;
    let frame = null;

    const flush = () => {
      frame = null;
      if (pending !== 0) {
        poke('creator:rotate', { delta: pending });
        pending = 0;
      }
    };

    tt.addEventListener('pointerdown', (e) => {
      dragging = true;
      lastX = e.clientX;
      tt.classList.add('dragging');
      tt.setPointerCapture(e.pointerId);
    });
    tt.addEventListener('pointermove', (e) => {
      if (!dragging) return;
      pending += (e.clientX - lastX) * 0.42;
      lastX = e.clientX;
      if (!frame) frame = requestAnimationFrame(flush);
    });
    const stop = (e) => {
      if (!dragging) return;
      dragging = false;
      tt.classList.remove('dragging');
      try { tt.releasePointerCapture(e.pointerId); } catch (_) {}
    };
    tt.addEventListener('pointerup', stop);
    tt.addEventListener('pointercancel', stop);
  },

  async cancel() {
    await post('creator:cancel', {});
    showScreen('characters');
    Characters.render();
  },

  async submit() {
    const first = $('firstName') ? $('firstName').value.trim() : '';
    const last = $('lastName') ? $('lastName').value.trim() : '';
    const dob = $('dob') ? $('dob').value.trim() : '';

    if (this.section !== 'identity') this.go('identity');

    const [fOk, fMsg] = check.name(first);
    if (fOk !== true) { mark('firstName', false, fMsg || 'Required.'); $('firstName').focus(); return; }
    const [lOk, lMsg] = check.name(last);
    if (lOk !== true) { mark('lastName', false, lMsg || 'Required.'); $('lastName').focus(); return; }
    const [dOk, dMsg] = check.dob(dob);
    if (dOk !== true) { mark('dob', false, dMsg || 'Required.'); $('dob').focus(); return; }

    const btn = $('creatorNext');
    busy(btn, true);
    showError($('creatorError'), null);

    const res = await post('creator:submit', {
      firstName: first,
      lastName: last,
      dob,
      backstory: $('backstory').value,
    });
    busy(btn, false);

    if (!res.ok) return showError($('creatorError'), res.error || 'That application was refused.');

    stamp('Approved');
    Characters.load(res.characters || [], Characters.max);
    showScreen('characters');
  },
};

/* =========================================================================
   04 / spawn
   ========================================================================= */

const Spawn = {
  spawns: [],
  selected: null,
  last: null,

  init() {
    $('spawnConfirm').addEventListener('click', () => this.confirm());
  },

  load(spawns, lastPosition, character) {
    this.spawns = spawns || [];
    this.last = lastPosition || null;
    this.selected = null;
    if (character) {
      $('spawnSub').textContent =
        `${character.firstName} ${character.lastName}. Pick a district - you can move once you are on the ground.`;
    }
    this.render();
  },

  render() {
    const ul = $('spawnList');
    const pins = $('pins');
    ul.innerHTML = '';
    pins.innerHTML = '';

    const entries = this.spawns.slice();
    if (this.last) {
      entries.unshift({
        id: 'last',
        label: 'Where you left off',
        area: 'Last known position',
        blurb: 'Pick up exactly where this character stopped.',
        map: null,
      });
    }

    entries.forEach((sp) => {
      const li = document.createElement('li');
      li.className = 'spawncard' + (this.selected === sp.id ? ' on' : '');
      li.tabIndex = 0;
      li.innerHTML = `
        <div class="spawnhead">
          <span class="spawnname"></span>
          <span class="spawnarea"></span>
        </div>
        <p class="spawnblurb"></p>`;
      li.querySelector('.spawnname').textContent = sp.label;
      li.querySelector('.spawnarea').textContent = sp.area;
      li.querySelector('.spawnblurb').textContent = sp.blurb;

      const pick = () => this.select(sp.id);
      li.addEventListener('click', pick);
      li.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } });
      ul.appendChild(li);

      if (sp.map) {
        const pin = document.createElement('div');
        pin.className = 'pin' + (this.selected === sp.id ? ' on' : '') + (sp.map.x > 0.6 ? ' flip' : '');
        pin.style.left = `${sp.map.x * 100}%`;
        pin.style.top = `${sp.map.y * 100}%`;
        pin.innerHTML = '<i class="pin-mark"></i><span class="pin-label"></span>';
        pin.querySelector('.pin-label').textContent = sp.label;
        pins.appendChild(pin);
      }
    });

    $('spawnConfirm').disabled = this.selected === null;
  },

  select(id) {
    this.selected = id;
    this.render();
    showError($('spawnError'), null);
    if (id !== 'last') poke('spawn:preview', { id });
  },

  async confirm() {
    if (this.selected === null) return;
    const btn = $('spawnConfirm');
    busy(btn, true);
    const res = await post('spawn:confirm', { id: this.selected });
    busy(btn, false);
    if (!res.ok) return showError($('spawnError'), res.error || 'Could not place you there.');
    stamp('Issued');
  },
};

/* =========================================================================
   messages from the client script
   ========================================================================= */

window.addEventListener('message', (event) => {
  const msg = event.data || {};

  if (msg.action === 'stage') {
    if (msg.stage === 'auth') {
      const d = msg.data || {};
      if (d.serverName) $('markName').textContent = d.serverName;
      if (d.serverTag) $('markTag').textContent = d.serverTag;
      $('fileSerial').textContent = fileSerial();
      showScreen('auth');
      setTimeout(() => $('username').focus(), 520);
    } else if (msg.stage === 'flying' || msg.stage === 'live') {
      /* the camera is doing the talking now */
      SCREENS.forEach((s) => { const el = $(`screen-${s}`); if (el) el.hidden = true; });
      document.body.classList.add('hidden');
    } else {
      showScreen(msg.stage);
    }
  }

  if (msg.action === 'creator:sync' && msg.data) {
    Creator.absorb(msg.data);
    Creator.renderAll();
  }
});

function fileSerial() {
  const y = new Date().getFullYear();
  const n = String(Math.floor(Math.random() * 900000) + 100000);
  return `${y}-${n}`;
}

/* Escape backs out of the creator; nothing else steals keys from the game. */
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape' && current === 'creator') Creator.cancel();
});

document.addEventListener('DOMContentLoaded', () => {
  Auth.init();
  Characters.init();
  Creator.init();
  Spawn.init();
  Auth.toggle();     /* start on "sign in"; the copy makes registering obvious */
});
