/* global React, NusantaraClient, NusantaraMapData */
(function (root) {
  const {useState, useRef, useEffect, useLayoutEffect, useCallback} = React;
  const base = '#campaign/nusantara';
  function loadBattleClientModule() {
    if (root.NusantaraBattleClient?.createBattleClient) return Promise.resolve(root.NusantaraBattleClient);
    if (root.NusantaraBattleClientPromise) return root.NusantaraBattleClientPromise;
    const loading = new Promise((resolve, reject) => {
      const script = document.createElement('script');
      script.type = 'module';
      script.src = '/campaign/battle-client.mjs';
      script.onload = () => root.NusantaraBattleClient?.createBattleClient
        ? resolve(root.NusantaraBattleClient)
        : reject(new Error('Campaign battle controller did not register.'));
      script.onerror = () => reject(new Error('Campaign battle controller could not be loaded.'));
      document.head.appendChild(script);
    });
    root.NusantaraBattleClientPromise = loading.catch(error => {
      root.NusantaraBattleClientPromise = null;
      throw error;
    });
    return root.NusantaraBattleClientPromise;
  }
  const narrative = [
    'Commander, your Card Collection is ready. Beyond the Hangar, five cities face different tests of trust and cooperation. In Nusantara Campaign, your crew will meet local pilots, learn what they protect, and help them find a way forward.',
    'Our first arc is Solo: Yang Dijaga, Yang Terlupakan. In this fictional city network, workshops need a livelihood, neighbourhoods need protection, and Keraton Vanguard fears another failed change. Listen to Kresna and Sadewa before you face Estri. This is not a story about taking their Meka; it is about earning their trust.',
    'Each encounter has three guaranteed rewards in order: 2 signature Combat Prints, 4 copies of its CMD, then 4 copies of its MD. A win completes the active reward; a loss records one third of its Imprint, so three losses complete it. Only a victory advances the story. Losing takes no cards and never resets your account progress. For now, we can inspect the campaign; battles are still in preparation.',
  ];
  function getRewardWins(progress, encounterId) {
    const value = Number(progress?.find(item => item.encounterId === encounterId)?.wins);
    return Number.isFinite(value) ? Math.min(3, Math.max(0, Math.trunc(value))) : 0;
  }
  function getRewardProgress(battle, encounterId, milestone) {
    const row = Array.isArray(battle?.rewardProgress)
      ? battle.rewardProgress.find(item => item?.encounterId === encounterId && Number(item.milestone) === milestone)
      : null;
    if (row) {
      const progress = [0,33,66,100].includes(Number(row.progress)) ? Number(row.progress) : 0;
      return {progress:row.acquired === true ? 100 : progress, acquired:row.acquired === true};
    }
    const wins = getRewardWins(battle?.progress, encounterId);
    return {progress:wins >= milestone ? 100 : 0, acquired:wins >= milestone};
  }
  function encounterRewardProgress(battle, encounterId) {
    return [1,2,3].map(milestone => getRewardProgress(battle, encounterId, milestone));
  }
  const cityGateEncounterIds = {
    palembang:'solo/boss',
    pontianak:'palembang/boss',
    makassar:'pontianak/boss',
    jayapura:'makassar/boss',
  };
  // Curated place notes use canonical Homebase codes. Add cities in reviewed batches;
  // an unlisted city still has its personal crew record, never invented local lore.
  // City fact sources are tracked in docs/superpowers/plans/2026-09-23-homebase-field-notes.md.
  const homebaseFieldNotes = {
    'ID-AC-BTJ': {
      preview: 'Masjid Raya Baiturrahman and the Aceh Tsunami Museum are two places of gathering and remembrance in Banda Aceh.',
      places: [
        {title:'MASJID RAYA BAITURRAHMAN', copy:'The grand mosque remains a place for worship and public remembrance in the heart of Banda Aceh.'},
        {title:'MUSEUM TSUNAMI ACEH', copy:'The museum preserves the memory of the 2004 Indian Ocean earthquake and tsunami through exhibits for learning and reflection.'},
      ],
    },
    'ID-SU-MDN': {
      preview: 'From Istana Maimun to the heritage streets of Kesawan, Medan carries more than one chapter of its past in plain sight.',
      places: [
        {title:'ISTANA MAIMUN', copy:'The palace stands in a recognized heritage district alongside the Grand Mosque and Taman Sri Deli.'},
        {title:'KESAWAN', copy:'Historic buildings, including the old post office, keep part of Medan’s earlier streetscape visible in the present city.'},
      ],
    },
    'ID-SB-PDG': {
      preview: 'Padang stretches from the old riverfront at Batang Arau toward the sea and Pantai Air Manis.',
      places: [
        {title:'BATANG ARAU', copy:'This historic riverfront is part of Padang’s old-city landscape, near Jembatan Siti Nurbaya and Gunung Padang.'},
        {title:'PANTAI AIR MANIS', copy:'The beach south of the city is closely linked to the Malin Kundang legend, a story told through its coastal landmarks.'},
      ],
    },
    'ID-RI-PKU': {
      preview: 'From riverside Senapelan to Masjid Raya An-Nur, Pekanbaru holds its trading roots and its gathering places side by side.',
      places: [
        {title:'SENAPELAN / SUNGAI SIAK', copy:'Pekanbaru grew from a trading settlement near the Siak River. The old Senapelan mosque recalls the Malay community at the city’s beginnings.'},
        {title:'MASJID RAYA AN-NUR', copy:'With its broad courtyard and distinctive domes, this mosque is a familiar place of worship and gathering in central Pekanbaru.'},
      ],
    },
    'ID-KR-BTM': {
      preview: 'Batam looks out to its islands from the Barelang bridges, while Dataran Engku Hamidah gives Batam Centre a place to gather after dark.',
      places: [
        {title:'BARELANG BRIDGE', copy:'The first bridge, named Raja Haji Fisabilillah, frames the sea and nearby islands. Its public viewing area is a place to take in Batam’s maritime setting.'},
        {title:'DATARAN ENGKU HAMIDAH', copy:'In Batam Centre, illuminated walkways and Malay-inspired ornament make this square a lively evening stop for residents and visitors.'},
      ],
    },
    'ID-JA-DJB': {
      preview: 'Gentala Arasy rises beside the Batanghari, while the story of Pasar Angso Duo follows the river’s long trading route.',
      places: [
        {title:'GENTALA ARASY', copy:'A pedestrian bridge crosses the Batanghari to the clock tower. At its base, a small museum tells part of Jambi’s Islamic history.'},
        {title:'PASAR ANGSO DUO', copy:'Goods once arrived by boat near the older market. Angso Duo became a lasting name in the city’s river-linked trading life.'},
      ],
    },
    'ID-SS-PLM': {
      preview: 'The Musi separates Palembang’s Ilir and Ulu banks. Jembatan Ampera connects them; Benteng Kuto Besak faces the river nearby.',
      places: [
        {title:'JEMBATAN AMPERA', copy:'Cross the bridge and you move between Seberang Ilir and Seberang Ulu, with the Musi beneath you.'},
        {title:'BENTENG KUTO BESAK', copy:'The Palembang Sultanate built this fort beside the Musi. From its riverfront grounds, you can see the Ampera bridge.'},
      ],
    },
    'ID-BB-PGP': {
      preview: 'Museum Timah Indonesia holds part of Pangkalpinang’s mining history; Pantai Pasir Padi lies about eight kilometres from the city centre.',
      places: [
        {title:'MUSEUM TIMAH INDONESIA', copy:'This heritage building houses exhibits on Bangka’s tin mining. Sukarno and other Republic leaders stayed here during their 1949 exile.'},
        {title:'PANTAI PASIR PADI', copy:'The broad, firm shore slopes toward the sea. At low tide, visitors can reach nearby Pulau Punai on foot.'},
      ],
    },
    'ID-BE-BKS': {
      preview: 'In Bengkulu, you can explore Benteng Marlborough’s British-era walls, then walk beneath casuarina trees along Pantai Panjang.',
      places: [
        {title:'BENTENG MARLBOROUGH', copy:'British builders raised this coastal fort between 1714 and 1719. You can trace its defensive plan through thick brick walls, a moat, and an open courtyard.'},
        {title:'PANTAI PANJANG', copy:'Residents come here to walk, exercise, and eat by the shore under the casuarina trees.'},
      ],
    },
    'ID-KB-PNK': {
      preview: 'The Kapuas and Landak rivers meet in Pontianak, a city marked by the equator.',
      places: [
        {title:'THE RIVER CITY', copy:'Pontianak grew where the Kapuas and Landak meet. Its waterways divide the city into distinct banks and keep them connected.'},
        {title:'TUGU KHATULISTIWA', copy:'The Equator Monument in North Pontianak marks the line that gives the city its familiar name: Kota Khatulistiwa.'},
      ],
    },
  };
  // Local Homebase Dossier gallery only. Do not use the gameplay artManifest:
  // publishing that contract also changes the Founding tutorial arena.
  const homebaseGallery = {
    'ID-AC-BTJ': {slug:'id-ac-btj',arena:'Coastal operations plaza',views:['Masjid Raya Baiturrahman','Museum Tsunami Aceh']},
    'ID-SU-MDN': {slug:'id-su-mdn',arena:'Medan operations plaza',views:['Istana Maimun','Kesawan heritage corridor']},
    'ID-SB-PDG': {slug:'id-sb-pdg',arena:'Padang coastal plaza',views:['Batang Arau and Jembatan Siti Nurbaya','Masjid Raya Sumatera Barat']},
    'ID-RI-PKU': {slug:'id-ri-pku',arena:'Riau Malay civic plaza',views:['Masjid Agung An-Nur','Siak River and Siak IV bridge']},
    'ID-KR-BTM': {slug:'id-kr-btm',arena:'Batam civic plaza',views:['Barelang Bridge','Batu Ampar harbor']},
    'ID-JA-DJB': {slug:'id-ja-djb',arena:'Jambi Malay civic plaza',views:['Gentala Arasy and Batanghari River','Tugu Keris Siginjai']},
  };
  function HomebaseGallery({homebase}) {
    const gallery = homebaseGallery[homebase.code];
    if (!gallery) return null;
    const views = [
      {key:'arena',title:gallery.arena},
      {key:'act-1',title:gallery.views[0]},
      {key:'act-2',title:gallery.views[1]},
    ];
    return <section className="homebase-gallery" aria-label={homebase.city+' city views'}>
      <span className="kicker">SCENES FROM {homebase.city}</span>
      {views.map((view,index) => <figure className={'homebase-gallery-view'+(index===0?' is-arena':'')} key={view.key}>
        <img data-homebase-art={view.key} src={'/campaign/homebase-gallery/'+gallery.slug+'/'+view.key+'.png'}
          alt={view.title+' in '+homebase.city} width="1672" height="941" loading="lazy" decoding="async"/>
        <figcaption><span>{index===0?'AT HOME':'AROUND THE CITY'}</span><strong>{view.title}</strong></figcaption>
      </figure>)}
    </section>;
  }
  function cityGatePresentation(city, battle) {
    if (!city || city.encounters) return {state:'available',status:'CITY AVAILABLE',copy:''};
    const gateEncounterId = cityGateEncounterIds[city.id];
    if (gateEncounterId && getRewardWins(battle?.progress, gateEncounterId) >= 1) {
      const securedCity = gateEncounterId.split('/')[0].toUpperCase();
      return {state:'preview',status:'CITY PREVIEW',copy:securedCity + ' SECURED. ' + city.name.toUpperCase() + ' BATTLES ARE NOT RELEASED YET.'};
    }
    return {state:'locked',status:'CITY LOCKED',copy:city.unlock};
  }
  const soloEncounterIds = ['solo/mini-1', 'solo/mini-2', 'solo/boss'];
  function encounterAvailability(battle, encounterId) {
    const released = Array.isArray(battle?.releasedEncounterIds) ? battle.releasedEncounterIds : ['solo/mini-1'];
    if (!released.includes(encounterId)) return 'preview';
    if (encounterId === 'solo/boss' && (getRewardWins(battle?.progress, 'solo/mini-1') < 1 || getRewardWins(battle?.progress, 'solo/mini-2') < 1)) return 'locked';
    return 'available';
  }
  function neutralBattleState(identity = '', status = identity ? 'loading' : 'idle') {
    return {identity,status,clientState:{attempt:null,busy:false,error:'',recovery:{status:'idle',pending:null}},setup:null,progress:[],rewardProgress:[],releasedEncounterIds:['solo/mini-1'],launchEnabled:false,error:''};
  }
  function projectBattleState(identity, battleState) {
    return identity && battleState?.identity === identity ? battleState : neutralBattleState(identity);
  }
  const soloBannerEncounters = {
    'solo/mini-1': {district:'Laweyan',crew:'Kresna'},
    'solo/mini-2': {district:'Banjarsari',crew:'Sadewa'},
    'solo/boss': {district:'Pasar Kliwon',crew:'Estri'},
  };
  function campaignBannerAvailability(campaign) {
    const battle = campaign?.battle;
    const ready = Boolean(campaign?.identity && battle?.identity === campaign.identity && battle.status === 'ready');
    if (!ready) return {status:'BATTLE ACCESS VERIFYING',copy:'Campaign access and released districts are being verified for this account. Your saved progress remains secured.',openEncounterIds:[]};
    if (!battle.launchEnabled) return {status:'NEW BATTLES PAUSED',copy:"Review Solo's released districts and your saved reward progress. New battle launches are currently paused.",openEncounterIds:[]};
    const openEncounterIds = soloEncounterIds.filter(encounterId => encounterAvailability(battle,encounterId) === 'available');
    if (!openEncounterIds.length) return {status:'NO BATTLES OPEN',copy:"Review Solo's campaign districts and your saved reward progress. No new battle is currently available.",openEncounterIds};
    const opponents = openEncounterIds.map(encounterId => `${soloBannerEncounters[encounterId].crew} in ${soloBannerEncounters[encounterId].district}`);
    const invitation = opponents.length === 1 ? opponents[0] : opponents.length === 2 ? opponents.join(' and ') : `${opponents.slice(0,-1).join(', ')}, and ${opponents.at(-1)}`;
    return {
      status:openEncounterIds.length === 1 ? `${soloBannerEncounters[openEncounterIds[0]].district.toUpperCase()} OPEN` : 'SOLO OPEN',
      copy:`Challenge ${invitation} and earn each open district's guaranteed reward path. Your progress is secured to your account.`,
      openEncounterIds,
    };
  }
  function useCampaign({account, request, route, refreshKey}) {
    const [state, setState] = useState({identity: '', status: 'idle', data: null, busy: false, error: ''});
    const [battleState, setBattleState] = useState({status:'idle', clientState:{attempt:null,busy:false,error:'',recovery:{status:'idle',pending:null}}, setup:null, progress:[], rewardProgress:[], releasedEncounterIds:['solo/mini-1'], launchEnabled:false, error:''});
    const [battleNonce, setBattleNonce] = useState(0);
    const [commandOpen, setCommandOpen] = useState(false);
    const closeCommand = useCallback(()=>setCommandOpen(false),[]);
    const client = useRef(null);
    const battleClient = useRef(null);
    if (!client.current) client.current = NusantaraClient.createClient({request, onChange: setState});
    const identity = account?.status === 'signed-in' ? String(account.organization?.id || '') : '';
    useEffect(()=>setCommandOpen(false),[identity,route]);
    useEffect(() => {
      if (client.current.getState().identity !== identity) void client.current.setIdentity(identity);
      else if (identity) void client.current.refresh();
    }, [identity, route, refreshKey]);
    useEffect(() => {
      if (!identity || state.identity !== identity) return;
      try { NusantaraClient.rememberDistrict({data:state.data,route,storage:window.localStorage}); } catch { /* Browser preferences can be disabled. */ }
      void client.current.acknowledgeNavigation(route);
    }, [identity, route, state.data]);
    useEffect(() => {
      const refresh = () => {if (document.visibilityState === 'visible') void client.current.refresh();};
      window.addEventListener('focus', refresh); document.addEventListener('visibilitychange', refresh);
      return () => {window.removeEventListener('focus', refresh); document.removeEventListener('visibilitychange', refresh);};
    }, []);
    useEffect(() => {
      let current = true;
      battleClient.current?.dispose();
      battleClient.current = null;
      if (!identity) {
        setBattleState({status:'idle',clientState:{attempt:null,busy:false,error:'',recovery:{status:'idle',pending:null}},setup:null,progress:[],rewardProgress:[],releasedEncounterIds:['solo/mini-1'],launchEnabled:false,error:''});
        return () => {current=false;};
      }
      setBattleState(neutralBattleState(identity));
      (async () => {
        try {
          const {createBattleClient, loadBattleHydration} = await loadBattleClientModule();
          const {status, setup, setupError} = await loadBattleHydration({request,organizationId:identity});
          if (!current) return;
          const activeEncounterId = status?.activeAttempt?.encounterId || 'solo/mini-1';
          const created = createBattleClient({request,organizationId:identity,encounterId:activeEncounterId,onChange:clientState=>{
            if(current) setBattleState(previous=>({...previous,clientState,error:clientState.error||previous.error}));
          }});
          battleClient.current = created;
          const activeId = status?.activeAttempt?.id || undefined;
          await created.resume(activeId);
          if (!current) return;
          setBattleState(previous=>({...previous,identity,status:'ready',setup,setupError,progress:Array.isArray(status?.progress)?status.progress:[],rewardProgress:Array.isArray(status?.rewardProgress)?status.rewardProgress:[],releasedEncounterIds:Array.isArray(status?.releasedEncounterIds)?status.releasedEncounterIds:['solo/mini-1'],launchEnabled:status?.launchEnabled===true,error:created.getState().error||''}));
        } catch (error) {
          if (!current) return;
          const unavailable = error?.status === 404;
          setBattleState(previous=>({...previous,identity,status:unavailable?'unavailable':'error',setup:null,progress:[],rewardProgress:[],releasedEncounterIds:['solo/mini-1'],launchEnabled:false,error:unavailable?'':(error?.message||'Battle authority could not be verified.')}));
        }
      })();
      return () => {current=false;battleClient.current?.dispose();battleClient.current=null;};
    }, [identity, battleNonce]);
    const openBattle = useCallback((encounterId = 'solo/mini-1') => {
      if (!soloEncounterIds.includes(encounterId) || encounterAvailability(battleState, encounterId) !== 'available') return;
      window.location.assign('/battle/bond-breach/?mode=campaign&organization='+encodeURIComponent(identity)+'&encounter='+encodeURIComponent(encounterId));
    }, [identity, battleState]);
    const launchBattle = useCallback(async (encounterId = 'solo/mini-1') => {
      if (!battleState.launchEnabled || !battleClient.current || encounterAvailability(battleState, encounterId) !== 'available') return null;
      const current = client.current.getState();
      if (current.busy || current.error || current.data?.introduction.revision < 5) return null;
      setCommandOpen(true);
      return null;
    }, [battleState]);
    const retryBattle = useCallback(async () => {
      if (!battleClient.current) { setBattleNonce(value => value + 1); return null; }
      const attempt = await battleClient.current.retry();
      setBattleNonce(value => value + 1);
      return attempt;
    }, [openBattle]);
    const refresh = useCallback(() => client.current.refresh(), []);
    const acknowledge = useCallback(() => client.current.acknowledge(), []);
    const continueIntro = useCallback(async () => {
      const step = client.current.getState().data?.introduction.step?.id;
      if (!await client.current.acknowledge()) return false;
      if (step === 'reward_rules') window.location.hash = '#hangar';
      if (step === 'hangar_entry') window.location.hash = base;
      if (step === 'solo_preview') window.location.hash = base + '/solo';
      return true;
    }, []);
    // Effects reset the client; render-time identity gating prevents even a one-frame leak.
    const visible = !identity ? {identity, status: account?.status === 'checking' ? 'loading' : 'signed-out', data:null, busy:false, error:''} : state.identity === identity ? state : {identity, status: 'loading', data: null, busy: false, error: ''};
    const currentBattle = Boolean(identity && battleState.identity === identity);
    return {...visible, refresh, acknowledge, continueIntro, battle:{...projectBattleState(identity,battleState),commandOpen:currentBattle&&commandOpen,closeCommand,deploy:openBattle,launch:launchBattle,retry:retryBattle}};
  }
  function BattleLaunchState({campaign, encounter}) {
    if (!encounter) return null;
    const battle = campaign.battle;
    const encounterId = 'solo/' + encounter.id;
    const availability = encounterAvailability(battle, encounterId);
    if (availability === 'preview') return <div className="nusantara-launch-state" role="status"><strong>PREVIEW · BATTLE NOT YET AVAILABLE</strong></div>;
    if (availability === 'locked') return <div className="nusantara-launch-state" role="status"><strong>LOCKED · SECURE BOTH MINI BOSS DISTRICTS</strong></div>;
    const attempt = battle?.clientState?.attempt;
    const wins = getRewardWins(battle?.progress, encounterId);
    const rewardsComplete = encounterRewardProgress(battle, encounterId).every(reward => reward.acquired);
    if (!battle || battle.status === 'unavailable') return <div className="nusantara-launch-state" role="status"><strong>BATTLE IN PREPARATION</strong></div>;
    if (battle.status === 'loading') return <div className="nusantara-launch-state" role="status"><strong>LOADING…</strong></div>;
    if (battle.status === 'error' || battle.clientState.error) return <div className="nusantara-launch-state is-error" role="alert"><strong>BATTLE CONNECTION INTERRUPTED</strong><span>{battle.error || battle.clientState.error}</span><button type="button" className="btn" onClick={battle.retry}>RETRY</button></div>;
    if (!battle.launchEnabled) return <div className="nusantara-launch-state" role="status"><strong>NEW BATTLES PAUSED</strong></div>;
    const canChoosePackage = battle.setupError?.status === 409 && ['DEPLOYMENT_PACKAGE_REQUIRED','DEPLOYMENT_PACKAGE_DECK_CHANGED','DEPLOYMENT_PACKAGE_CREW_CHANGED'].includes(battle.setupError.code || battle.setupError.body?.code);
    if (battle.setupError && !canChoosePackage) return <div className="nusantara-launch-state is-error" role="alert"><strong>BATTLE CONNECTION INTERRUPTED</strong><span>{battle.setupError.message || 'Deployment Package could not be verified.'}</span><button type="button" className="btn" onClick={battle.retry}>RETRY</button></div>;
    const terminalLabel = attempt?.status === 'won' ? 'LAST RESULT / VICTORY' : attempt?.status === 'lost' ? 'LAST RESULT / DEFEAT' : '';
    const launchNote = wins > 0 && rewardsComplete
      ? 'DISTRICT SECURED · ALL THREE REWARDS ACQUIRED — FURTHER BATTLES ARE FRIENDLY PRACTICE.'
      : wins > 0
        ? 'DISTRICT SECURED · A WIN COMPLETES THE ACTIVE REWARD; A LOSS RECORDS ONE THIRD OF ITS IMPRINT.'
        : 'WIN TO SECURE THE DISTRICT AND COMPLETE THE ACTIVE REWARD; A LOSS RECORDS ONE THIRD WITHOUT ADVANCING THE STORY.';
    return <div className={'nusantara-launch-state is-ready' + (terminalLabel ? ' has-terminal' : '')} aria-busy={battle.clientState.busy?'true':undefined}>
      {terminalLabel && <span>{terminalLabel}</span>}
      <button type="button" className="btn primary" disabled={campaign.busy || Boolean(campaign.error) || campaign.data?.introduction.revision < 5 || battle.clientState.busy || !battle.launchEnabled} onClick={()=>battle.launch(encounterId)}>{battle.clientState.busy?'CONNECTING…':wins>0&&rewardsComplete?'START PRACTICE BATTLE':wins>0?'BATTLE AGAIN':'START BATTLE'}</button>
      <small>{launchNote}</small>
    </div>;
  }
  function CampaignBanner({campaign}) {
    if (!campaign?.data?.introduction.canEnterMap) return null;
    const intro = campaign.data.introduction;
    const guided = intro.step?.id === 'hangar_entry';
    let storage;
    try { storage = window.localStorage; } catch { /* Optional visual preference. */ }
    const view = NusantaraClient.bannerView({data:campaign.data,storage,
      progress:campaign.battle?.status === 'ready' && campaign.battle.identity === campaign.identity ? campaign.battle.progress : undefined});
    const availability = campaignBannerAvailability(campaign);
    const openDistricts = availability.openEncounterIds.length;
    return <section className="section nusantara-banner-section" aria-label="Nusantara Campaign">
      <div className="section-inner"><div className="nusantara-banner">
        {view.background && <img className="nusantara-banner-background" src={view.background} alt="" data-district={view.district}/>}
        <div className="operations-sim-scanlines" aria-hidden="true"/>
        <div className="nusantara-banner-copy"><div className="nusantara-banner-eyebrow"><span>FEATURED THEATER</span><span className="nusantara-banner-status">{availability.status}</span></div>
          <h2>NUSANTARA<br/>CAMPAIGN</h2><p>{availability.copy}</p>
          <div className="nusantara-banner-facts"><span><b>05</b> CITIES</span><span><b>{String(openDistricts).padStart(2,'0')}</b> {openDistricts === 1 ? 'OPEN DISTRICT' : 'OPEN DISTRICTS'}</span><span className="is-secured"><b>{view.secured ?? '—'}</b> SECURED</span></div>
        </div>
        <div className="nusantara-banner-action">
          <button type="button" className="btn primary" data-restricted-tutorial-target={guided ? 'true' : undefined} disabled={campaign.busy} onClick={() => guided ? campaign.continueIntro() : (window.location.hash = base)}><span>{campaign.busy ? 'OPENING…' : guided ? 'BEGIN CAMPAIGN →' : 'EXPLORE CAMPAIGN →'}</span></button>
        </div>
      </div></div>
    </section>;
  }
  function homebaseStarterCrew(account) {
    const starterId = String(account?.organization?.starterMechId || '').toUpperCase();
    const meka = Array.isArray(root.MEKABOTS_DATA) ? root.MEKABOTS_DATA.find(item => item?.id === starterId) : null;
    const pilot = meka?.pilot?.callsign || meka?.pilot?.name || 'PILOT RECORD PENDING';
    return starterId ? pilot + ' · ' + starterId + (meka?.codename ? ' / ' + meka.codename : '') : 'FOUNDING CREW PENDING';
  }
  function HomebaseDossier({homebase, account, onClose, headingRef}) {
    const organization = account?.organization || {};
    const fieldNote = homebaseFieldNotes[homebase.code];
    const starterMekaId = String(organization.starterMechId || '').toUpperCase();
    return <aside id="nusantara-homebase-dossier" className="dossier homebase-dossier" aria-labelledby="nusantara-homebase-title">
      <button type="button" className="homebase-dossier-close" onClick={onClose}>◂ BACK TO CAMPAIGN</button>
      <span className="kicker">YOUR HOMEBASE</span>
      <h2 id="nusantara-homebase-title" ref={headingRef} tabIndex={-1}>{homebase.city}</h2>
      <span className="homebase-province">{homebase.province}</span>
      <p className="homebase-intro">{organization.name || organization.callsign || 'Your crew'} calls this city home.</p>
      <HomebaseGallery homebase={homebase}/>
      {fieldNote && <section className="homebase-field-note" aria-label={homebase.city+' city notes'}>
        <span className="kicker">THE PLACE</span>
        <p className="homebase-field-note-preview">{fieldNote.preview}</p>
        {fieldNote.places.map(place => <div className="homebase-place" key={place.title}><strong>{place.title}</strong><p>{place.copy}</p></div>)}
      </section>}
      <div className="homebase-crew"><span className="kicker">YOUR FOUNDING CREW</span><strong>{homebaseStarterCrew(account)}</strong></div>
      <p className="homebase-crew-context">Your founding records stay with you while the Campaign moves on to its next city.</p>
      <nav className="homebase-record-links" aria-label="Your founding records">
        {organization.id && <a className="homebase-operator-link" href={'#org/'+encodeURIComponent(organization.id)}>VIEW OPERATOR DOSSIER →</a>}
        {starterMekaId && <a className="homebase-operator-link" href={'#mech/'+encodeURIComponent(starterMekaId)}>INSPECT STARTER MEKA →</a>}
        <a className="homebase-operator-link" href="#deckbay">FIND FOUNDING DECK IN DECK BAY →</a>
      </nav>
    </aside>;
  }
  function CampaignPage({campaign, route, artwork = null, CommandChannel, account, request}) {
    const pageRef = useRef(null);
    const viewRef = useRef(null);
    const headingRef = useRef(null);
    const cityHeadingRef = useRef(null);
    const mapRef = useRef(null);
    const overlayRef = useRef(null);
    const galleryTrigger = useRef(null);
    const homebaseTrigger = useRef(null);
    const restoreHomebaseFocus = useRef(false);
    const homebaseHeadingRef = useRef(null);
    const previousRoute = useRef(route);
    const [gallery, setGallery] = useState(null);
    const [homebaseDossierOpen, setHomebaseDossierOpen] = useState(false);
    const data = campaign.data;
    const cities = data?.theater?.cities || [];
    const homebase = data?.homebase || null;
    const homebaseFieldNote = homebase && homebaseFieldNotes[homebase.code];
    const overlapCity = homebase?.campaignCityId ? cities.find(item => item.id === homebase.campaignCityId) || null : null;
    const parts = route.slice(base.length).split('/').filter(Boolean);
    const city = cities.find(item => item.id === parts[0]);
    const encounter = city?.encounters?.find(item => item.id === parts[1]);
    const step = data?.introduction.step?.id;
    const invalid = Boolean(parts.length > 2 || (parts[0] && !city) || (parts[1] && !encounter));
    const productionArt = !invalid && encounter?.artwork ? encounter.artwork : null;
    const matchingOverride = !invalid && encounter && artwork?.cityId === city.id && artwork.encounterId === encounter.id ? artwork : null;
    const art = productionArt && matchingOverride ? {...productionArt, ...matchingOverride} : matchingOverride || productionArt;
    const galleryOpen = Boolean(art && gallery?.route === route && data?.introduction.canEnterMap);
    // Refit when navigation adds/removes the host header without remounting this page.
    useLayoutEffect(() => {
      const measure = () => {
        const top = Math.max(0, (pageRef.current?.getBoundingClientRect().top || 0) + window.scrollY);
        pageRef.current?.style.setProperty('--nusantara-top', top + 'px');
      };
      measure();
      window.addEventListener('resize', measure);
      return () => window.removeEventListener('resize', measure);
    }, [route]);
    useEffect(() => {
      const previousCity = previousRoute.current.slice(base.length).split('/').filter(Boolean)[0];
      previousRoute.current = route;
      setGallery(null);
      setHomebaseDossierOpen(false);
      const frame = requestAnimationFrame(() => {
        const returningPin = !city && cities.some(item => item.id === previousCity)
          ? mapRef.current?.querySelector('[data-campaign-city="' + previousCity + '"]') : null;
        (returningPin || cityHeadingRef.current || headingRef.current)?.focus({preventScroll:true});
      });
      return () => cancelAnimationFrame(frame);
    }, [route, Boolean(data?.introduction.canEnterMap)]);
    useLayoutEffect(() => {
      if (!galleryOpen) return;
      const view = viewRef.current;
      const wasInert = view?.inert;
      if (view) view.inert = true;
      overlayRef.current?.querySelector('button')?.focus({preventScroll:true});
      return () => {
        if (view) view.inert = wasInert;
        if (galleryTrigger.current?.isConnected) galleryTrigger.current.focus({preventScroll:true});
      };
    }, [galleryOpen]);
    useLayoutEffect(() => {
      if (homebaseDossierOpen) {
        homebaseHeadingRef.current?.focus({preventScroll:true});
        return;
      }
      if (!restoreHomebaseFocus.current) return;
      restoreHomebaseFocus.current = false;
      if (homebaseTrigger.current?.isConnected) homebaseTrigger.current.focus({preventScroll:true});
    }, [homebaseDossierOpen]);
    function selectCity(item) {
      if (campaign.busy) return;
      if (item.id === 'solo' && step === 'solo_preview') return campaign.continueIntro();
      window.location.hash = base + '/' + item.id;
    }
    function openHomebaseDossier(event) {
      if (!homebase || campaign.busy) return;
      homebaseTrigger.current = event?.currentTarget || null;
      restoreHomebaseFocus.current = false;
      setHomebaseDossierOpen(true);
    }
    function closeHomebaseDossier() {
      restoreHomebaseFocus.current = true;
      setHomebaseDossierOpen(false);
    }
    function openGallery(group, event) {
      galleryTrigger.current = event.currentTarget;
      setGallery({group, route});
    }
    function closeGallery() {setGallery(null);}
    function galleryKeyDown(event) {
      if (event.key === 'Escape') {event.preventDefault(); event.stopPropagation(); closeGallery(); return;}
      if (event.key !== 'Tab') return;
      const controls = Array.from(overlayRef.current?.querySelectorAll('button, a[href], [tabindex="0"]') || []);
      const first = controls[0], last = controls[controls.length - 1];
      if (!first) return;
      if (event.shiftKey && document.activeElement === first) {event.preventDefault(); last.focus();}
      else if (!event.shiftKey && document.activeElement === last) {event.preventDefault(); first.focus();}
    }
    const crewTiles = art ? [
      {src:art.mekaFullbody, name:encounter.signature, alt:encounter.signature + ' front and rear views', group:'crew'},
      {src:art.mekaPreview, name:encounter.signature, alt:encounter.signature + ' standing', group:'crew'},
      {src:art.pilotFullbody, name:encounter.pilot, alt:encounter.pilot + ' suit and helmet views', group:'crew'},
    ].filter(item => item.src) : [];
    const arenaName = art?.arenaName || encounter?.district || '';
    const locationTiles = art ? [
      {src:art.arena, name:arenaName, alt:arenaName + ' campaign arena', group:'loc'},
      {src:art.battle1, name:arenaName, alt:arenaName + ' battle staging area one', group:'loc'},
      {src:art.battle2, name:arenaName, alt:arenaName + ' battle staging area two', group:'loc'},
    ].filter(item => item.src) : [];
    const galleryTiles = gallery?.group === 'crew' ? crewTiles : locationTiles;
    const pageHead = <header className="bar nusantara-head"><div>{encounter && <span className="kicker">{'NUSANTARA CAMPAIGN / ' + city.name}</span>}
      <h1 ref={headingRef} tabIndex={-1}>{encounter ? encounter.district : <>ADVENTURE MODE <span>/ NUSANTARA CAMPAIGN</span></>}</h1></div>
      <a className="backbtn nusantara-back" href={encounter ? base + '/' + city.id : '#hangar'}>{encounter ? '◂ BACK TO ' + city.name.toUpperCase() : '◂ MY HANGAR'}</a></header>;
    if (!data || !data.introduction.canEnterMap) return <main ref={pageRef} className="nusantara-page"><div className="nusantara-presentation"><div className="view on">{pageHead}<div className="nusantara-unavailable" role="status">
      <h2>{campaign.status === 'signed-out' ? 'SIGN IN TO CONTINUE' : campaign.status === 'disabled' || campaign.error ? 'CAMPAIGN CONNECTION INTERRUPTED' : !data ? 'CONNECTING TO COMMAND…' : !data.eligible ? 'COMPLETE YOUR HANGAR BRIEFING' : 'COMMAND BRIEFING'}</h2>
      <p>{campaign.error || (campaign.status === 'disabled' ? 'The campaign link could not be reached. Reconnect to continue your mission.' : data?.reason || (data?.eligible ? 'Your assistant will introduce the story before we open the map.' : 'Your assistant in My Hangar will guide you through the remaining preparation.'))}</p>
      {(campaign.error || campaign.status === 'disabled') && <button className="btn primary" onClick={campaign.refresh}>RETRY CONNECTION</button>}
      {data && !data.eligible && <a className="btn primary" href="#hangar">CONTINUE PREPARATION</a>}
      {campaign.status === 'signed-out' && <a className="btn primary" href="#signin">SIGN IN</a>}
    </div></div></div></main>;
    const wins = getRewardWins(campaign.battle?.progress, city?.id + '/' + encounter?.id);
    const rewardStages = encounter ? encounterRewardProgress(campaign.battle, city.id + '/' + encounter.id) : [];
    const rewardsComplete = rewardStages.length === 3 && rewardStages.every(reward => reward.acquired);
    const nextRewardMilestone = rewardStages.findIndex(reward => !reward.acquired) + 1;
    const selectedCityGate = cityGatePresentation(city,campaign.battle);
    const primaryEncounterId = city?.encounters?.find(item => encounterAvailability(campaign.battle,city.id+'/'+item.id) === 'available'
      && getRewardWins(campaign.battle?.progress,city.id+'/'+item.id) < 1)?.id
      || city?.encounters?.find(item => encounterAvailability(campaign.battle,city.id+'/'+item.id) === 'available')?.id
      || '';
    return <main ref={pageRef} className={'nusantara-page' + (encounter ? ' is-encounter' : city ? ' has-city' : '')}>
      {campaign.battle?.commandOpen && encounter && CommandChannel && <CommandChannel key={account.organization.id+':'+city.id+'/'+encounter.id} account={account} request={request} city={city} encounter={encounter} artwork={art} onClose={campaign.battle.closeCommand} onDeploy={()=>campaign.battle.deploy(city.id+'/'+encounter.id)}/>}
      <div className="nusantara-presentation">
      <div ref={viewRef} className="view on">
      {pageHead}
      {campaign.error && <div className="nusantara-error" role="alert">{campaign.error} <button className="btn" onClick={campaign.refresh}>RETRY</button></div>}
      {invalid ? <div className="nusantara-unavailable"><h2>LOCATION NOT FOUND</h2><a className="backbtn nusantara-back" href={base}>◂ BACK TO MAP</a></div> : encounter ? <article key={city.id+'/'+encounter.id} className={'encgrid nusantara-encounter-file' + (art ? ' has-art' : '')} aria-label={encounter.district + ' encounter preview'}>
        {art && <div className="scene nusantara-encounter-scene">
          <img className="mekaart nusantara-meka-art" src={art.mekaCinematic || art.arena} alt={art.mekaCinematic ? encounter.signature + ' cinematic portrait' : 'Tugu Batik Laweyan arena'} fetchpriority="high"/>
          <div className="flash" aria-hidden="true"/>
          {art.arena && <div className="arena-tag"><span className="kicker accent">ARENA / {city.name}</span><b>{art.arenaName || 'TUGU BATIK LAWEYAN'}</b></div>}
          <div className="scenecap"><strong>{encounter.signature}</strong><span className="sub">SIGNATURE MEKA · FIELDED BY {encounter.pilot}</span></div>
          <div className="scan" aria-hidden="true"/>
          {wins >= 1 && <div className="stamp">{rewardsComplete ? 'ALL REWARDS ACQUIRED' : 'DISTRICT SECURED'}</div>}
        </div>}
        <div className="encfile nusantara-encounter-brief">
          <span className="kicker">{encounter.role}</span>
          <div className="pilothead">{art?.pilotFace && <img className="nusantara-pilot-face" src={art.pilotFace} alt={encounter.pilot + ' face'}/>}<h2>{encounter.pilot}</h2></div>
          <p className="crewline">{city.organization} · FIELDING <b>{encounter.signature}</b></p>
          <div className="launch"><BattleLaunchState campaign={campaign} encounter={encounter}/></div>
          <p className="story nusantara-encounter-story">{encounter.summary}</p>
          <section className="rewards nusantara-encounter-rewards" aria-label="Victory rewards">
            <span className="kicker accent">YOUR REWARD PATH</span>
            <h3>Three Imprints. Three guaranteed rewards.</h3>
            <ol className="rtrack nusantara-reward-track">{encounter.rewards.map(reward => {
              const stage=rewardStages[reward.victory-1]||{progress:0,acquired:false};
              const rewardState = stage.acquired ? 'ACQUIRED' : nextRewardMilestone === reward.victory ? 'NEXT REWARD' : 'FUTURE REWARD';
              const rewardImage = reward.artwork || (reward.victory === 1 ? art?.mekaCard : reward.victory === 2 ? art?.commandCard : art?.mdCard);
              return <li key={reward.victory} className={rewardState === 'ACQUIRED' ? 'acq' : rewardState === 'NEXT REWARD' ? 'next' : ''} data-reward-state={rewardState.toLowerCase().replace(' ','-')}>
                {rewardState === 'ACQUIRED' && <span className="chk" aria-hidden="true">✓</span>}
                <span className="rnum">0{reward.victory}<small>STAGE</small></span>
                {rewardImage && <img src={rewardImage} alt={reward.name + ' ' + reward.kind + ' illustration'}/>}
                <span className="kind">{reward.kind}</span><strong>{reward.name} <b>×{reward.quantity}</b></strong>
                <span className="state">{stage.progress>0&&!stage.acquired?'IMPRINT '+String(stage.progress).padStart(3,'0')+' / 100':rewardState} · {reward.extra}</span>
              </li>;
            })}</ol>
          </section>
          {art && <div className="artstrip">
            <div className="lbl"><span className="kicker accent">CREW &amp; LOCATIONS</span><span className="kicker">CLICK A TILE TO VIEW FULLSCREEN</span></div>
            <div className="row">{[...crewTiles,...locationTiles].map((tile,index) => <button key={tile.src} type="button" className="tile" aria-label={'View ' + tile.alt} aria-haspopup="dialog" onClick={event => openGallery(tile.group, event)}>
              <img src={tile.src} alt={tile.alt} loading="lazy"/><span className="cap">{tile.name}</span>
            </button>)}</div>
          </div>}
        </div>
      </article> : <div className={'theater nusantara-theater' + (city || homebaseDossierOpen ? ' city' : '')}>
        <section className="mapwrap nusantara-map-region" aria-label="Campaign map and cities">
          <div className="maplabel"><span className="kicker">THEATER MAP / 5 CITIES</span></div>
          <div className="mapbox" ref={mapRef}>
            <svg viewBox="0 0 1224 600" preserveAspectRatio="xMidYMid meet" role="group" aria-label="Map of Indonesia with campaign locations in Solo, Palembang, Pontianak, Makassar and Jayapura">
              {NusantaraMapData.map(item => <path key={item.name} d={item.path} className={item.name === 'Indonesia' ? 'land' : 'neighbour'}/>)}
              {cities.map((item,index) => {
                const cx=(item.coordinate[0]-93)*24, cy=(10-item.coordinate[1])*24;
                const labelAbove=item.id==='solo'||item.id==='makassar';
                const gate=cityGatePresentation(item,campaign.battle);
                const isHomebase = overlapCity?.id === item.id;
                return <g key={item.id} className={'cpin' + (item.id==='solo'?' first':'') + (city?.id===item.id?' sel':'') + (!item.encounters?' lock':'') + (isHomebase?' homebase-composite':'')}
                  role="button" tabIndex={0} data-campaign-city={item.id} data-city-state={gate.state} aria-label={(isHomebase?'Homebase and Campaign location. ':'')+'Inspect '+item.name+(index?(gate.state==='preview'?'. '+(isHomebase?'Campaign operations preview':'Preview')+': battles not released yet':'. '+(isHomebase?'Campaign operations locked':'Locked')+': '+item.unlock):'. First city')}
                  data-homebase-composite={isHomebase ? homebase.code : undefined}
                  aria-disabled={campaign.busy || undefined} aria-expanded={city?.id===item.id} aria-controls={city?.id===item.id?'nusantara-city-panel':undefined}
                  data-restricted-tutorial-target={item.id==='solo'&&step==='solo_preview'?'true':undefined}
                  onClick={()=>selectCity(item)} onKeyDown={event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();selectCity(item);}}}>
                  <rect className="pin-hit" x={cx-22} y={cy-22} width="44" height="44" fill="transparent"/>
                  <rect className="d" x="-7" y="-7" width="14" height="14" transform={'translate('+cx+','+cy+') rotate(45)'}/>
                  <circle className="ring" cx={cx} cy={cy} r="10"/>
                  <text x={cx} y={labelAbove?cy-15:cy+26} textAnchor="middle">{item.name}</text>
                </g>;
              })}
              {homebase && !overlapCity && (() => {
                const cx=(homebase.coordinate[0]-93)*24, cy=(10-homebase.coordinate[1])*24;
                return <g ref={homebaseTrigger} className={'hpin'+(homebaseDossierOpen?' sel':'')} role="button" tabIndex={0} data-homebase-pin={homebase.code}
                  aria-label={'Open Homebase Dossier for '+homebase.city+', '+homebase.province} aria-expanded={homebaseDossierOpen}
                  aria-controls="nusantara-homebase-dossier" onClick={openHomebaseDossier}
                  onKeyDown={event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();openHomebaseDossier(event);}}}>
                  <circle className="pin-hit" cx={cx} cy={cy} r="24" fill="transparent"/>
                  <path className="homebase-mark" d="M0 -10 L9 -2 L6 10 L-6 10 L-9 -2 Z" transform={'translate('+cx+','+cy+')'}/>
                  <circle className="ring" cx={cx} cy={cy} r="13"/>
                  <text x={cx} y={cy+30} textAnchor="middle">HOMEBASE · {homebase.city}</text>
                </g>;
              })()}
            </svg>
            <div className="griddots" aria-hidden="true"/><div className="sweep" aria-hidden="true"/>
          </div>
          <p className="mapcap">{city ? 'Select another pin to change city.' : <>Select a city to explore its story. Your first destination is Solo. <span>Tab to move between city pins.</span></>}</p>
        </section>
        {homebaseDossierOpen && homebase ? <HomebaseDossier homebase={homebase} account={account} onClose={closeHomebaseDossier} headingRef={homebaseHeadingRef}/> : city && <aside key={city.id} id="nusantara-city-panel" className="dossier nusantara-dossier" aria-label={city.name+' sector file'}>
          <span className="kicker">SECTOR FILE / {city.region || city.island}</span><h2 ref={cityHeadingRef} tabIndex={-1}>{city.name}</h2>
          {overlapCity?.id === city.id && <div className="homebase-identity-summary" aria-label="Your Homebase identity">
            <span className="kicker">YOUR HOMEBASE</span>
            <strong>{account?.organization?.name || account?.organization?.callsign || 'UNREGISTERED'}</strong>
            <span>{homebase.city}, {homebase.province}</span>
            {homebaseFieldNote && <p className="homebase-teaser">{homebaseFieldNote.preview}</p>}
          </div>}
          {overlapCity?.id === city.id && <button ref={homebaseTrigger} type="button" className="homebase-dossier-toggle" aria-controls="nusantara-homebase-dossier" aria-expanded="false" onClick={openHomebaseDossier}>HOMEBASE DOSSIER</button>}
          {!city.encounters ? <><div className={'lockbox is-'+selectedCityGate.state+(overlapCity?.id === city.id?' homebase-future':'')}><b>{overlapCity?.id === city.id ? 'CAMPAIGN STORY / COMING LATER' : selectedCityGate.status}</b><p>{selectedCityGate.copy}</p><p>{overlapCity?.id === city.id ? 'Your Homebase record is here to explore while this city’s story is still ahead.' : "Two mini bosses and one big boss are planned here. This city's story and crew assets will follow."}</p></div><div className="hintline"><a href={base}>HIDE DETAILS</a></div></> : <>
            <span className="arc">{city.arc}</span><p className="sum">{city.summary}</p>
            <div className="operator"><div className="operator-crest" aria-hidden="true">{city.organizationLogoUrl ? <img data-asset-role="organization-crest" src={city.organizationLogoUrl} alt="" width="44" height="44"/> : <div/>}</div><div><span className="kicker accent">RULING OPERATOR</span><b>{city.organization}</b><span className="kicker">HOLDS {city.name} · CREWS POSTED ACROSS {city.encounters.length} DISTRICTS</span></div></div>
            <span className="kicker">1 OBJECTIVE · 3 DIRECTIVES</span>
            {city.encounters.map((item,index) => {
              const itemWins = getRewardWins(campaign.battle?.progress, city.id + '/' + item.id);
              const itemRewards = encounterRewardProgress(campaign.battle, city.id + '/' + item.id);
              const acquiredRewards = itemRewards.filter(reward => reward.acquired).length;
              const secured = itemWins >= 1;
              return <a className={'encrow' + (secured ? ' sec' : '') + (item.id===primaryEncounterId?' is-primary-progression':'')} key={item.id} href={base+'/'+city.id+'/'+item.id} data-primary-progression={item.id===primaryEncounterId?'true':undefined} data-restricted-tutorial-target={step==='encounter_preview'&&index===0?'true':undefined}>
                <span className="num">{secured ? '✓' : '0'+(index+1)}</span><div><span className="role">{item.role}</span><strong>{item.district}{secured && <><span className="badge-sec">SECURED</span><span className="pips" aria-label={acquiredRewards+' of 3 rewards acquired'}>{[1,2,3].map(value => <i key={value} className={value <= acquiredRewards ? 'f' : ''}/>)}</span></>}</strong><small>{item.pilot+' · '+item.signature+(secured?' · '+acquiredRewards+'/3 REWARDS ACQUIRED':'')+(encounterAvailability(campaign.battle,city.id+'/'+item.id)==='preview'?' · PREVIEW — BATTLE NOT YET AVAILABLE':encounterAvailability(campaign.battle,city.id+'/'+item.id)==='locked'?' · LOCKED — SECURE BOTH MINI BOSSES':'')}</small></div><span className="go">VIEW →</span>
              </a>;
            })}
            <p className="hintline">Choose a district to meet your opponent and review its rewards. · <a href={base}>HIDE DETAILS</a></p>
          </>}
        </aside>}
      </div>}
      </div>
      {galleryOpen && <div ref={overlayRef} className="overlay on" role="dialog" aria-modal="true" aria-labelledby="nusantara-gallery-title" tabIndex={-1} onKeyDown={galleryKeyDown}>
        <header><h3 id="nusantara-gallery-title">{gallery.group==='crew'?'CREW FILE — '+encounter.pilot+' / '+encounter.signature:'BATTLE LOCATIONS — '+city.name}</h3><button type="button" className="backbtn" onClick={closeGallery}>CLOSE</button></header>
        <div className="artgrid">{galleryTiles.map((tile,index)=><figure key={tile.src}><img src={tile.src} alt={tile.alt}/><figcaption>{tile.name}</figcaption></figure>)}</div>
      </div>}
      </div>
    </main>;
  }
  function CampaignFlow({campaign, account, route, blocked, Dialogue, Assistant, avatarSource, trapFocus}) {
    const flowRef = useRef(null);
    const [otherModal, setOtherModal] = useState(true);
    const [minimizedKey, setMinimizedKey] = useState('');
    const intro = campaign.data?.introduction;
    const key = campaign.identity + ':' + intro?.step?.id;
    useLayoutEffect(() => {
      const check = () => setOtherModal(Array.from(document.querySelectorAll('[aria-modal="true"]')).some(node => !flowRef.current?.contains(node) && node.getClientRects().length > 0));
      check(); const observer = new MutationObserver(check);
      observer.observe(document.body, {childList:true, subtree:true, attributes:true, attributeFilter:['class','style','hidden','aria-modal']});
      return () => observer.disconnect();
    }, []);
    const surface = NusantaraClient.presentation(campaign, route, blocked || otherModal);
    const fullscreen = surface === 'fullscreen';
    const battleAvailability = campaignBannerAvailability(campaign);
    const introductionLines = battleAvailability.openEncounterIds.includes('solo/mini-1')
      ? [...narrative.slice(0,2), 'Each encounter has three guaranteed rewards in order: 2 signature Combat Prints, 4 copies of its CMD, then 4 copies of its MD. A win completes the active reward; a loss records one third of its Imprint, so three losses complete it. Only a victory advances the story. Losing takes no cards and never resets your account progress. Laweyan battle access is live through your registered Deployment Package.']
      : battleAvailability.status === 'NEW BATTLES PAUSED'
        ? [...narrative.slice(0,2), 'Each encounter has three guaranteed rewards in order: 2 signature Combat Prints, 4 copies of its CMD, then 4 copies of its MD. A win completes the active reward; a loss records one third of its Imprint, so three losses complete it. Only a victory advances the story. Losing takes no cards and never resets your account progress. New battle launches are currently paused.']
      : narrative;
    useLayoutEffect(() => {
      if (!fullscreen) return;
      const previous = document.activeElement;
      const shells = Array.from(document.querySelectorAll('.app-shell, [data-campaign-qa-shell]'));
      const previousInert = shells.map(node => node.inert);
      shells.forEach(node => {node.inert = true;});
      flowRef.current?.querySelector('[role="dialog"]')?.focus();
      return () => {shells.forEach((node,index) => {node.inert = previousInert[index];}); if (previous?.isConnected && !previous.closest('[inert]')) previous.focus({preventScroll:true});};
    }, [fullscreen]);
    if (surface === 'none') return <div ref={flowRef} className="nusantara-flow"/>;
    if (fullscreen) return <div ref={flowRef} className="nusantara-flow is-fullscreen" onKeyDownCapture={event => {if (event.key === 'Tab') trapFocus(event, flowRef.current);}}>
      <Dialogue id="nusantara-introduction" ariaLabel="Nusantara Campaign introduction" lines={introductionLines} stepIndex={intro.stepIndex} assistantName={account.organization?.assistantName || 'ANVILGEIST'} avatarSource={avatarSource} busy={campaign.busy} primaryLabel={campaign.error ? 'RETRY' : 'CONTINUE'} explicitPrimaryLabel onAdvance={campaign.continueIntro} error={campaign.error}/>
    </div>;
    const step = intro.step.id;
    const guidance = NusantaraClient.routeGuidance(step, route);
    const advance = async () => {
      if (guidance.acknowledge && !await campaign.continueIntro()) return;
      if (guidance.targetHref) window.location.hash = guidance.targetHref;
    };
    return <div ref={flowRef} className="nusantara-flow is-sidebar"><Assistant account={account} telemetry={{}} tutorialStep={0}
      briefing={{label:'CAMPAIGN LINK', copy:guidance.copy, stepIndex:intro.stepIndex - 3, totalSteps:3, actionLabel:guidance.actionLabel}}
      minimized={minimizedKey === key} onDismiss={() => setMinimizedKey(key)} onReopen={() => setMinimizedKey('')} onAdvance={advance} busy={campaign.busy} error={campaign.error}/></div>;
  }
  root.NusantaraUI = {useCampaign, CampaignBanner, CampaignPage, CampaignFlow, projectBattleState};
})(globalThis);
