{"id":1112,"date":"2026-10-14T19:02:03","date_gmt":"2026-10-14T17:02:03","guid":{"rendered":"https:\/\/www.hashdezo.fr\/?page_id=1112"},"modified":"2026-10-23T10:52:43","modified_gmt":"2026-10-23T08:52:43","slug":"alm-city","status":"publish","type":"page","link":"https:\/\/www.hashdezo.fr\/index.php\/alm-city\/","title":{"rendered":"ALM City"},"content":{"rendered":"\n<div class=\"hz-alm-city\" style=\"width:100%;max-width:1440px;margin:0 auto\"><iframe id=\"hz-alm-frame\" title=\"ALM City, d\u00e9monstrateur SAP Cloud ALM\" style=\"display:block;width:100%;height:max(680px,calc(100vh - 120px));border:0;border-radius:6px;background:#0a0f26\"><\/iframe><\/div>\n<script type=\"text\/plain\" id=\"hz-alm-src\">\n<!doctype html><html lang=\"fr\"><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>ALM City<\/title>\n<link rel=\"preconnect\" href=\"https:\/\/fonts.googleapis.com\">\n<link rel=\"preconnect\" href=\"https:\/\/fonts.gstatic.com\" crossorigin>\n<link rel=\"stylesheet\" href=\"https:\/\/fonts.googleapis.com\/css2?family=Chakra+Petch:wght@500;600;700&#038;family=IBM+Plex+Sans:wght@400;500;600&#038;family=JetBrains+Mono:wght@400;500;700&#038;display=swap\">\n<style>\n:root{\n  color-scheme: dark;\n  --bg:#0a0f26;\n  --panel:#10173a;\n  --panel-2:#161f4c;\n  --line:#28336a;\n  --fg:#e9ecff;\n  --muted:#97a0d0;\n  --gold:#ffb547;\n  --cyan:#4fd8ff;\n  --ok:#4be3a0;\n  --warn:#ffcf4a;\n  --crit:#ff5a6e;\n  --ink-on-gold:#1a1205;\n  --f-display:\"Chakra Petch\",\"Arial Narrow\",Arial,sans-serif;\n  --f-body:\"IBM Plex Sans\",system-ui,-apple-system,\"Segoe UI\",sans-serif;\n  --f-mono:\"JetBrains Mono\",ui-monospace,SFMono-Regular,Menlo,monospace;\n}\nhtml,body{height:100%}\nbody{margin:0;background:var(--bg);color:var(--fg);font-family:var(--f-body);font-size:14px;line-height:1.45}\n[hidden]{display:none!important}\n*{box-sizing:border-box}\nbutton{font:inherit;color:inherit}\n:focus-visible{outline:2px solid var(--cyan);outline-offset:2px}\n\n.app{min-height:100%;display:flex;flex-direction:column;gap:12px;\n  padding-left:max(16px, env(safe-area-inset-left,0px));\n  padding-right:max(16px, env(safe-area-inset-right,0px));\n  padding-block:12px}\n\n.bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 20px}\n.brand{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;min-width:0}\n.brand h1{margin:0;font-family:var(--f-display);font-weight:700;font-size:26px;letter-spacing:.04em;text-transform:uppercase;line-height:1}\n.brand h1 span{color:var(--cyan)}\n.brand p{margin:0;color:var(--muted);font-size:13px}\n.brand p b{color:var(--fg);font-weight:600}\n.modes{display:flex;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:var(--panel)}\n.modes button{background:none;border:0;padding:8px 16px;cursor:pointer;display:flex;flex-direction:column;align-items:flex-start;gap:1px;min-width:118px}\n.modes button+button{border-left:1px solid var(--line)}\n.modes b{font-family:var(--f-display);font-size:16px;letter-spacing:.12em}\n.modes small{font-size:11px;color:var(--muted)}\n.modes button[aria-pressed=\"true\"][data-mode=\"build\"]{background:var(--gold);color:var(--ink-on-gold)}\n.modes button[aria-pressed=\"true\"][data-mode=\"run\"]{background:var(--cyan);color:#04121a}\n.modes button[aria-pressed=\"true\"] small{color:inherit;opacity:.75}\n.modes button:disabled{cursor:not-allowed;opacity:.55}\n.modes button.ready{animation:ready 1.2s ease-in-out infinite}\n@keyframes ready{50%{box-shadow:inset 0 0 0 2px var(--cyan)}}\n\n.main{display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:12px;height:clamp(580px, calc(100dvh - 128px), 940px)}\n.stage{position:relative;min-width:0;border:1px solid var(--line);border-radius:6px;overflow:hidden;background:var(--bg)}\n#city{display:block;width:100%;height:100%;touch-action:manipulation}\n.hud-top{position:absolute;top:10px;left:10px;right:10px;display:flex;flex-wrap:wrap;gap:6px;pointer-events:none}\n.chip{font-family:var(--f-mono);font-size:11px;letter-spacing:.06em;text-transform:uppercase;padding:4px 8px;border-radius:4px;background:rgba(10,15,38,.72);border:1px solid var(--line);color:var(--fg);white-space:nowrap}\n.chip.gold{color:var(--gold);border-color:rgba(255,181,71,.45)}\n.chip.cyan{color:var(--cyan);border-color:rgba(79,216,255,.45)}\n.legend{position:absolute;left:10px;bottom:10px;display:flex;flex-wrap:wrap;gap:6px 12px;max-width:calc(100% - 20px);padding:6px 10px;border-radius:4px;background:rgba(10,15,38,.72);border:1px solid var(--line);font-size:11px;color:var(--muted);pointer-events:none}\n.legend span{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}\n.legend i{display:inline-block;width:14px;height:3px;border-radius:2px}\n.legend .q{width:12px;height:12px;border-radius:2px;transform:rotate(45deg);background:var(--gold)}\n#toasts{position:absolute;top:44px;right:10px;width:min(300px, calc(100% - 20px));display:flex;flex-direction:column;gap:6px;pointer-events:none}\n.toast{background:rgba(16,23,58,.94);border:1px solid var(--line);border-left:3px solid var(--gold);border-radius:4px;padding:8px 10px;animation:tin .25s ease-out}\n.toast.crit{border-left-color:var(--crit)}\n.toast.ok{border-left-color:var(--ok)}\n.toast.cyan{border-left-color:var(--cyan)}\n.toast b{display:block;font-family:var(--f-display);font-size:13px;letter-spacing:.04em}\n.toast span{display:block;font-size:12px;color:var(--muted)}\n@keyframes tin{from{transform:translateX(16px);opacity:0}}\n#banner{position:absolute;left:50%;top:42%;transform:translate(-50%,-50%);text-align:center;padding:16px 28px;background:rgba(10,15,38,.92);border:2px solid var(--gold);border-radius:6px;box-shadow:0 0 40px rgba(255,181,71,.25);pointer-events:none;width:max-content;max-width:calc(100% - 32px);animation:pop .35s cubic-bezier(.2,1.6,.4,1)}\n#banner .k{font-family:var(--f-mono);font-size:11px;letter-spacing:.2em;text-transform:uppercase;color:var(--gold)}\n#banner .t{font-family:var(--f-display);font-weight:700;font-size:clamp(24px,4vw,40px);letter-spacing:.06em;text-transform:uppercase;line-height:1.1;margin:4px 0;text-wrap:balance}\n#banner .s{font-size:13px;color:var(--muted);text-wrap:balance}\n@keyframes pop{from{transform:translate(-50%,-50%) scale(.7);opacity:0}}\n#tip{position:absolute;pointer-events:none;background:rgba(10,15,38,.95);border:1px solid var(--line);border-radius:4px;padding:8px 10px;max-width:240px;font-size:12px}\n#tip b{display:block;font-family:var(--f-display);font-size:14px;letter-spacing:.03em}\n#tip span{display:block;color:var(--muted)}\n#tip em{display:block;font-style:normal;margin-top:4px;font-family:var(--f-mono);font-size:11px}\n\n.panel{min-width:0;background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:16px;overflow:auto;display:flex;flex-direction:column;gap:16px}\n.panel h3{margin:0 0 8px;font-family:var(--f-mono);font-size:11px;font-weight:500;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)}\n.eyebrow{margin:0;font-family:var(--f-mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;color:var(--cyan)}\n.phases{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px}\n.phases li{font-family:var(--f-display);font-size:12px;font-weight:600;text-align:center;padding:6px 2px;border:1px solid var(--line);border-radius:4px;color:var(--muted);overflow:hidden;text-overflow:ellipsis}\n.phases li.done{background:rgba(255,181,71,.14);border-color:rgba(255,181,71,.5);color:var(--gold)}\n.phases li.cur{background:var(--gold);border-color:var(--gold);color:var(--ink-on-gold)}\n.xp-row{display:flex;justify-content:space-between;gap:8px;font-size:12px;margin-bottom:6px}\n.xp-row b{font-family:var(--f-display);font-size:14px;letter-spacing:.03em}\n.xp-row span{font-family:var(--f-mono);color:var(--muted);font-variant-numeric:tabular-nums}\n.xp-bar{height:10px;border-radius:2px;background:var(--panel-2);border:1px solid var(--line);overflow:hidden}\n.xp-bar i{display:block;height:100%;background:repeating-linear-gradient(-45deg,var(--gold) 0 6px,#ffc771 6px 12px);transition:width .5s ease}\n.quest{background:var(--panel-2);border:1px solid rgba(255,181,71,.45);border-radius:6px;padding:14px;display:flex;flex-direction:column;gap:8px}\n.quest.bad{border-color:var(--crit);box-shadow:0 0 0 1px var(--crit) inset}\n.quest.gl{border-color:var(--cyan)}\n.quest h4{margin:0;font-family:var(--f-display);font-size:19px;line-height:1.2;font-weight:600;text-wrap:balance}\n.quest p{margin:0;font-size:13px;color:var(--muted)}\n.quest .hint b{color:var(--gold)}\n.btn{border:0;border-radius:4px;padding:10px 14px;font-family:var(--f-display);font-weight:600;font-size:14px;letter-spacing:.06em;text-transform:uppercase;cursor:pointer}\n.btn.primary{background:var(--gold);color:var(--ink-on-gold)}\n.btn.primary:hover{background:#ffc66d}\n.btn.cyan{background:var(--cyan);color:#04121a}\n.btn.ghost{background:none;border:1px solid var(--line);color:var(--muted)}\n.btn.ghost:hover{color:var(--fg);border-color:var(--muted)}\n.campaign{display:flex;flex-direction:column;gap:10px}\n.qg-name{margin:0 0 4px;font-family:var(--f-display);font-size:13px;font-weight:600;color:var(--fg)}\n.campaign ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}\n.campaign li{display:grid;grid-template-columns:12px minmax(0,1fr);column-gap:8px;font-size:12px;color:var(--muted)}\n.campaign li i{width:8px;height:8px;margin-top:5px;border:1px solid var(--line);transform:rotate(45deg)}\n.campaign li em{grid-column:2;font-style:normal;font-family:var(--f-mono);font-size:10px;letter-spacing:.04em;opacity:.8}\n.campaign li.done{color:var(--fg)}\n.campaign li.done i{background:var(--gold);border-color:var(--gold)}\n.campaign li.cur{color:var(--gold)}\n.campaign li.cur i{border-color:var(--gold);animation:blink 1s steps(2) infinite}\n@keyframes blink{50%{background:var(--gold)}}\n.kpis{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.kpi{background:var(--panel-2);border:1px solid var(--line);border-radius:4px;padding:10px}\n.kpi span{display:block;font-size:11px;color:var(--muted)}\n.kpi b{display:block;font-family:var(--f-display);font-size:24px;font-weight:600;font-variant-numeric:tabular-nums;line-height:1.2}\n.kpi small{font-family:var(--f-mono);font-size:10px;color:var(--muted)}\n.lines{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}\n.lines li{display:flex;align-items:center;gap:8px;font-size:13px}\n.lines i{width:16px;height:4px;border-radius:2px;flex:none}\n.lines span{flex:1;min-width:0}\n.st{font-family:var(--f-mono);font-size:11px;font-weight:500;padding:2px 6px;border-radius:3px;background:rgba(75,227,160,.14);color:var(--ok)}\n.st.warn{background:rgba(255,207,74,.14);color:var(--warn)}\n.st.crit{background:rgba(255,90,110,.16);color:var(--crit)}\n.alerts{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:6px}\n.alert{border:1px solid var(--line);border-left:3px solid var(--warn);border-radius:4px;background:var(--panel-2)}\n.alert.crit{border-left-color:var(--crit)}\n.a-head{width:100%;background:none;border:0;padding:8px 10px;display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:8px;align-items:baseline;text-align:left;cursor:pointer}\n.a-head .sev{font-family:var(--f-mono);font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:var(--warn)}\n.alert.crit .sev{color:var(--crit)}\n.a-head .a-t{font-size:13px;font-weight:500}\n.a-head .age{font-family:var(--f-mono);font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}\n.a-body{padding:0 10px 10px;display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--muted)}\n.a-body p{margin:0}\n.a-body .app{font-family:var(--f-mono);font-size:11px;color:var(--cyan);letter-spacing:.03em}\n.empty{margin:0;font-size:13px;color:var(--muted);padding:10px;border:1px dashed var(--line);border-radius:4px}\n.log{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;font-family:var(--f-mono);font-size:11px;color:var(--muted)}\n.log li{display:grid;grid-template-columns:auto minmax(0,1fr);gap:8px}\n.log time{color:var(--fg);opacity:.55}\n.log .ok{color:var(--ok)} .log .crit{color:var(--crit)} .log .warn{color:var(--warn)}\n.foot-actions{display:flex;flex-wrap:wrap;gap:8px;margin-top:auto}\n\nfooter{font-size:12px;color:var(--muted);display:flex;flex-wrap:wrap;gap:4px 16px;justify-content:space-between}\nfooter a{color:var(--cyan)}\nfooter p{margin:0}\n\n@media (max-width:880px){\n  .main{grid-template-columns:minmax(0,1fr);height:auto}\n  .stage{height:min(66vh,560px);min-height:380px}\n  .panel{overflow:visible}\n  .modes button{min-width:0}\n}\n@media (prefers-reduced-motion: reduce){\n  *{animation:none!important;transition:none!important}\n}\n<\/style><\/head><body>\n\n<div class=\"app\">\n  <header class=\"bar\">\n    <div class=\"brand\">\n      <h1>ALM <span>City<\/span><\/h1>\n      <p><b>Nov\u00e9a Industries<\/b> \u00b7 projet RISE with SAP \u00b7 client fictif<\/p>\n    <\/div>\n    <div class=\"modes\" role=\"group\" aria-label=\"Mode de la ville\">\n      <button id=\"mBuild\" data-mode=\"build\" aria-pressed=\"true\"><b>BUILD<\/b><small>Impl\u00e9mentation<\/small><\/button>\n      <button id=\"mRun\" data-mode=\"run\" aria-pressed=\"false\" disabled><b>RUN<\/b><small>D\u00e9bloqu\u00e9 au go-live<\/small><\/button>\n    <\/div>\n  <\/header>\n\n  <main class=\"main\">\n    <section class=\"stage\" aria-label=\"Ville du syst\u00e8me d'information de Nov\u00e9a\">\n      <canvas id=\"city\" role=\"img\" aria-label=\"Vue isom\u00e9trique du landscape SAP de Nov\u00e9a : S\/4HANA, BTP, Integration Suite, SuccessFactors, Ariba et la tour Cloud ALM\"><\/canvas>\n      <div class=\"hud-top\" id=\"hud\"><\/div>\n      <div id=\"toasts\" aria-live=\"polite\"><\/div>\n      <div id=\"banner\" hidden><div class=\"k\"><\/div><div class=\"t\"><\/div><div class=\"s\"><\/div><\/div>\n      <div id=\"tip\" hidden><\/div>\n      <div class=\"legend\" id=\"legend\"><\/div>\n    <\/section>\n    <aside class=\"panel\" id=\"panel\" aria-live=\"polite\"><\/aside>\n  <\/main>\n\n  <footer>\n    <p>D\u00e9monstrateur HASHdezO \u00b7 Client fictif, donn\u00e9es synth\u00e9tiques, aucun syst\u00e8me r\u00e9el connect\u00e9.<\/p>\n    <p>Inspir\u00e9 par <a href=\"https:\/\/integration-city.nachomunozalvarez.com\/\" target=\"_blank\" rel=\"noopener\">Integration City<\/a> de Nacho Mu\u00f1oz \u00c1lvarez. SAP et SAP Cloud ALM sont des marques de SAP SE.<\/p>\n  <\/footer>\n<\/div>\n\n<script>\n(() => {\n'use strict';\nconst $ = s => document.querySelector(s);\nconst reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;\n\nconst hexA = h => { h = h.replace('#',''); return [parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)]; };\nconst lerpA = (a,b,t) => a.map((v,i)=>v+(b[i]-v)*t);\nconst mulA = (a,f) => a.map(v=>Math.max(0,Math.min(255,v*f)));\nconst str = (a,al=1) => `rgba(${a.map(v=>Math.round(v)).join(',')},${al})`;\nconst SKY = { dTop:hexA('#2a2f6e'), dBot:hexA('#d98a6f'), nTop:hexA('#050920'), nBot:hexA('#141c4a') };\nconst GROUND = { d1:hexA('#355373'), d2:hexA('#31506f'), dR:hexA('#59628c'), n1:hexA('#141d44'), n2:hexA('#121a3f'), nR:hexA('#212a5c') };\nconst NAVY = hexA('#0d1440');\nconst C = { gold:'#ffb547', cyan:'#4fd8ff', ok:'#4be3a0', warn:'#ffcf4a', crit:'#ff5a6e' };\n\nconst N = 13;\nconst ROADX = [5,9], ROADY = [5,9];\nconst BLD = {\n  dev:  {name:'S\/4HANA DEV', sub:'Syst\u00e8me de d\u00e9veloppement', x:2,y:2,w:2,d:2,h:2.0,hue:'#6f8bff',p:.2},\n  qas:  {name:'S\/4HANA QAS', sub:'Qualification et tests', x:2,y:6,w:2,d:2,h:2.0,hue:'#6f8bff',p:.15},\n  prd:  {name:'S\/4HANA PRD', sub:'Production, Private Edition', x:2,y:10,w:2,d:2,h:3.0,hue:'#5470ff',p:.12},\n  btp:  {name:'SAP BTP', sub:'Extensions et portail client', x:6,y:2,w:2,d:2,h:2.4,hue:'#a77bff',p:.1},\n  alm:  {name:'SAP Cloud ALM', sub:'Tour de contr\u00f4le du projet puis des op\u00e9rations', x:6,y:6,w:2,d:2,h:.5,tower:4.6,hue:'#4fd8ff',p:.3},\n  cpi:  {name:'Integration Suite', sub:'Flux entre S\/4HANA et le cloud', x:6,y:10,w:2,d:2,h:1.6,hue:'#2ec4b6',p:.1},\n  sf:   {name:'SuccessFactors', sub:'Ressources humaines', x:10,y:2,w:2,d:2,h:2.2,hue:'#ff8f5a',p:.1},\n  ecc:  {name:'SAP ECC', sub:'Ancien ERP, \u00e0 d\u00e9commissionner', x:10,y:6,w:2,d:2,h:2.6,hue:'#7c8199',p:1},\n  ariba:{name:'SAP Ariba', sub:'Achats et fournisseurs', x:10,y:10,w:2,d:2,h:1.9,hue:'#f2c14e',p:.1},\n};\nfor (const k in BLD) { const b = BLD[k]; b.id = k; b.tp = b.p; b.c = hexA(b.hue); b.top = 1; b.hull = null; b.flash = 0; }\nconst ORDER = Object.values(BLD).sort((a,b)=>(a.x+a.y)-(b.x+b.y));\n\nconst PHASES = [\n  {name:'Prepare', quests:[\n    {t:'Cr\u00e9er le projet RISE dans Cloud ALM', b:'alm', app:'Projects & Setup', gain:{alm:.4}},\n    {t:'Rattacher le landscape DEV, QAS, PRD', b:'dev', app:'Landscape Management', gain:{dev:.25,qas:.15,prd:.1,alm:.1}},\n  ]},\n  {name:'Explore', quests:[\n    {t:'Importer les processus SAP Best Practices', b:'alm', app:'Process Management', gain:{alm:.2,dev:.1}},\n    {t:'Atelier Fit-to-Standard Order-to-Cash', b:'dev', app:'Requirements', gain:{dev:.25,btp:.2}},\n    {t:'Capturer les exigences RH', b:'sf', app:'Requirements', gain:{sf:.4}},\n  ]},\n  {name:'Realize', quests:[\n    {t:'Construire le portail client sur BTP', b:'btp', app:'Features', gain:{btp:.7,dev:.2}},\n    {t:'Configurer les flux Ariba dans Integration Suite', b:'cpi', app:'Tasks', gain:{cpi:.8,ariba:.4}},\n    {t:'Tests d\u2019int\u00e9gration Order-to-Cash', b:'qas', app:'Test Management', failFirst:true, gain:{qas:.7}},\n    {t:'Tests utilisateurs Procure-to-Pay', b:'ariba', app:'Test Management', gain:{ariba:.5,sf:.5,cpi:.2}},\n  ]},\n  {name:'Deploy', quests:[\n    {t:'Transporter les features DEV vers QAS puis PRD', b:'prd', app:'Change & Deployment', trucks:true, gain:{prd:.6,qas:.15}},\n    {t:'Cutover : d\u00e9commissionner SAP ECC', b:'ecc', app:'Tasks \u00b7 plan de cutover', demolish:true},\n    {t:'Go-live de Nov\u00e9a sur S\/4HANA', b:'prd', app:'Projects & Setup', golive:true},\n  ]},\n  {name:'Run', quests:[]},\n];\nconst TOTAL_XP = PHASES.reduce((s,p)=>s+p.quests.length,0)*100;\n\nconst LINES = [\n  {id:'o2c', name:'Order-to-Cash', color:'#4fd8ff', off:-.2, path:[[5.5,11.6],[5.5,3]], bldgs:['prd','btp']},\n  {id:'p2p', name:'Procure-to-Pay', color:'#ffcf4a', off:.2, path:[[5.5,11],[5.5,9.5],[9.5,9.5],[9.5,11]], bldgs:['prd','cpi','ariba']},\n  {id:'h2r', name:'Hire-to-Retire', color:'#ff8f5a', off:0, path:[[9.5,3],[9.5,9.5],[5.5,9.5],[5.5,11.2]], bldgs:['sf','cpi','prd']},\n];\nfor (const l of LINES) { l.pts = l.path.map(([x,y])=>[x+l.off,y+l.off]); l.len = pathLen(l.pts); l.trains = [{u:0},{u:l.len*.9}]; }\n\nconst INC = [\n  {b:'cpi', sev:'crit', app:'Integration & Exception Monitoring', title:'Commandes d\u2019achat bloqu\u00e9es vers Ariba', cause:'Erreur de mapping sur le code fournisseur. 37 messages en \u00e9chec.', action:'Corriger et relancer'},\n  {b:'prd', sev:'warn', app:'Business Process Monitoring', title:'Retards de livraison Order-to-Cash', cause:'412 commandes clients sans livraison depuis plus de 48 h.', action:'Lancer l\u2019analyse'},\n  {b:'prd', sev:'crit', app:'Job & Automation Monitoring', title:'Job de facturation nocturne en \u00e9chec', cause:'Le job s\u2019est arr\u00eat\u00e9 \u00e0 63 %. Les factures du jour ne sont pas g\u00e9n\u00e9r\u00e9es.', action:'Relancer le job'},\n  {b:'sf', sev:'warn', app:'Real User Monitoring', title:'Temps de r\u00e9ponse d\u00e9grad\u00e9 sur SuccessFactors', cause:'Temps moyen pass\u00e9 de 1,2 s \u00e0 4,8 s sur l\u2019\u00e9cran des absences.', action:'Analyser les requ\u00eates'},\n  {b:'btp', sev:'crit', app:'Synthetic User Monitoring', title:'Connexion au portail client en \u00e9chec', cause:'Le sc\u00e9nario synth\u00e9tique \u00e9choue depuis 3 ex\u00e9cutions.', action:'Diagnostiquer'},\n  {b:'qas', sev:'warn', app:'Health Monitoring', title:'Espace disque QAS \u00e0 88 %', cause:'Croissance rapide des logs apr\u00e8s la derni\u00e8re campagne de tests.', action:'Purger les logs'},\n  {b:'ariba', sev:'warn', app:'Integration & Exception Monitoring', title:'Accus\u00e9s de r\u00e9ception fournisseurs en retard', cause:'File d\u2019attente de 120 messages c\u00f4t\u00e9 Ariba.', action:'Relancer la file'},\n  {b:'dev', sev:'warn', app:'Health Monitoring', title:'Certificat client expirant dans 7 jours', cause:'Certificat utilis\u00e9 par la connexion DEV vers BTP.', action:'Renouveler'},\n];\n\nconst ROADSEGS = [[[5.5,.3],[5.5,12.7]],[[9.5,.3],[9.5,12.7]],[[.3,5.5],[12.7,5.5]],[[.3,9.5],[12.7,9.5]]];\nconst TRAFFIC = Array.from({length:34},(_,i)=>({seg:i%4, u:Math.random()*12.4, dir:Math.random()<.5?1:-1, sp:.6+Math.random()*1.2, side:Math.random()<.5?-.12:.12}));\nconst STARS = Array.from({length:110},()=>[Math.random(),Math.random()*.55,Math.random()*6.28]);\n\nconst state = {\n  mode:'build', night:0, phase:0, quest:0, xp:0, golive:false, defect:null,\n  eccDemo:0, eccDemoing:false, eccGone:false, trucks:[], parts:[],\n  incidents:[], sel:null, nextSpawn:3, avail:99.97, mttr:[], resolved:0, ach:new Set(),\n  log:[], uptime:0, beamT:0, beamIdx:0, time:0, hover:null,\n};\nlet incSeq = 0;\n\nconst cv = $('#city'), ctx = cv.getContext('2d');\nlet W=0,H=0,dpr=1,th=20,ox=0,oy=0;\nfunction resize(){\n  const r = cv.parentElement.getBoundingClientRect();\n  dpr = Math.min(2, window.devicePixelRatio||1);\n  W = r.width; H = r.height;\n  cv.width = Math.round(W*dpr); cv.height = Math.round(H*dpr);\n  ctx.setTransform(dpr,0,0,dpr,0,0);\n  const tw = Math.min(W\/(N+1), 2*(H-70)\/(N+6.5));\n  th = tw\/2;\n  ox = W\/2;\n  oy = (H-(N+5.5)*th)\/2 + 5*th + 10;\n}\nconst iso = (x,y,z=0) => [ox+(x-y)*th, oy+(x+y)*th\/2 - z*th*.95];\nfunction pathLen(p){ let s=0; for(let i=1;i<p.length;i++) s+=Math.hypot(p[i][0]-p[i-1][0],p[i][1]-p[i-1][1]); return s; }\nfunction pointAt(p,s){\n  for(let i=1;i<p.length;i++){\n    const l = Math.hypot(p[i][0]-p[i-1][0],p[i][1]-p[i-1][1]);\n    if(s<=l){ const t=l?s\/l:0; return [p[i-1][0]+(p[i][0]-p[i-1][0])*t, p[i-1][1]+(p[i][1]-p[i-1][1])*t]; }\n    s-=l;\n  }\n  return p[p.length-1].slice();\n}\nfunction poly(pts, fill, stroke, lw=1){\n  ctx.beginPath(); ctx.moveTo(pts[0][0],pts[0][1]);\n  for(let i=1;i<pts.length;i++) ctx.lineTo(pts[i][0],pts[i][1]);\n  ctx.closePath();\n  if(fill){ ctx.fillStyle=fill; ctx.fill(); }\n  if(stroke){ ctx.strokeStyle=stroke; ctx.lineWidth=lw; ctx.stroke(); }\n}\nfunction inPoly(pt, vs){\n  let x=pt[0], y=pt[1], ins=false;\n  for(let i=0,j=vs.length-1;i<vs.length;j=i++){\n    const xi=vs[i][0], yi=vs[i][1], xj=vs[j][0], yj=vs[j][1];\n    if(((yi>y)!==(yj>y)) && (x < (xj-xi)*(y-yi)\/(yj-yi)+xi)) ins=!ins;\n  }\n  return ins;\n}\nconst hash = (a,b,c) => { const v = Math.sin(a*127.1+b*311.7+c*74.7)*43758.5453; return v-Math.floor(v); };\n\nfunction drawBox(x,y,w,d,h,c,z0=0,win=null){\n  const t=z0+h;\n  const A=iso(x,y,t),B=iso(x+w,y,t),Cc=iso(x+w,y+d,t),D=iso(x,y+d,t);\n  const Bb=iso(x+w,y,z0),Cb=iso(x+w,y+d,z0),Db=iso(x,y+d,z0);\n  poly([D,Cc,Cb,Db], str(mulA(c,.55)));\n  poly([B,Cc,Cb,Bb], str(mulA(c,.78)));\n  poly([A,B,Cc,D], str(mulA(c,1.12)), 'rgba(255,255,255,.18)');\n  if(win && state.night>.05 && h>.3){\n    const floors = Math.max(1,Math.floor(h\/.36)), cols = Math.max(2,Math.round(w*2.2));\n    const s = Math.max(1.4, th*.085);\n    for(let k=0;k<floors;k++) for(let j=0;j<cols;j++){\n      const z = z0+(k+.5)*h\/floors;\n      for(let f=0;f<2;f++){\n        const r = hash(win.seed,j+f*13,k);\n        const flick = r>.97 ? (Math.sin(state.time*3+r*40)>0) : true;\n        if(r<.62 &#038;&#038; flick){\n          const p = f===0 ? iso(x+w, y+(j+.5)\/cols*d, z) : iso(x+(j+.5)\/cols*w, y+d, z);\n          ctx.fillStyle = str(win.color, state.night*(.55+.4*r));\n          ctx.fillRect(p[0]-s\/2, p[1]-s*.7, s, s*1.4);\n        }\n      }\n    }\n  }\n  return [A,B,Bb,Cb,Db,D];\n}\n\nfunction drawScaffold(x,y,w,d,h,cur){\n  const t=h;\n  const A=iso(x,y,t),B=iso(x+w,y,t),Cc=iso(x+w,y+d,t),D=iso(x,y+d,t);\n  const Ab=iso(x,y,0),Bb=iso(x+w,y,0),Cb=iso(x+w,y+d,0),Db=iso(x,y+d,0);\n  ctx.save();\n  ctx.setLineDash([3,3]); ctx.lineWidth=1; ctx.strokeStyle='rgba(255,181,71,.6)';\n  ctx.beginPath();\n  [[A,B],[B,Cc],[Cc,D],[D,A],[A,Ab],[B,Bb],[Cc,Cb],[D,Db]].forEach(([p,q])=>{ctx.moveTo(p[0],p[1]);ctx.lineTo(q[0],q[1]);});\n  ctx.stroke();\n  ctx.setLineDash([]);\n  const m0 = iso(x+.2,y+.2,cur), m1 = iso(x+.2,y+.2,t+.9), arm = iso(x+w*.95,y+.2,t+.9);\n  const swing = Math.sin(state.time*.8)*.15;\n  const hook = iso(x+w*(.6+swing),y+.2,t+.9), hook2 = iso(x+w*(.6+swing),y+.2,cur+.4);\n  ctx.strokeStyle=C.gold; ctx.lineWidth=2;\n  ctx.beginPath(); ctx.moveTo(m0[0],m0[1]); ctx.lineTo(m1[0],m1[1]); ctx.lineTo(arm[0],arm[1]); ctx.stroke();\n  ctx.lineWidth=1; ctx.strokeStyle='rgba(255,181,71,.7)';\n  ctx.beginPath(); ctx.moveTo(hook[0],hook[1]); ctx.lineTo(hook2[0],hook2[1]); ctx.stroke();\n  ctx.fillStyle=C.gold; ctx.fillRect(hook2[0]-2,hook2[1],4,4);\n  ctx.restore();\n}\n\nfunction label(text, x, y, color='#e9ecff'){\n  const fs = Math.max(9, Math.min(12, th*.42));\n  ctx.font = `600 ${fs}px \"JetBrains Mono\", ui-monospace, monospace`;\n  const w = ctx.measureText(text).width + 10;\n  ctx.fillStyle='rgba(10,15,38,.78)';\n  ctx.fillRect(x-w\/2, y-fs*.85, w, fs*1.6);\n  ctx.fillStyle=color; ctx.textAlign='center'; ctx.textBaseline='middle';\n  ctx.fillText(text, x, y);\n}\n\nfunction topLabel(text,cx,cy,z,maxW,maxH,c){\n  const K=100, parts=text.split(' ');\n  const opts=[[text.replace('|','')]]; if(text.includes('|')) opts.push(text.split('|')); else if(parts.length>1){ const i=Math.ceil(parts.length\/2); opts.push([parts.slice(0,i).join(' '),parts.slice(i).join(' ')]); }\n  const p=iso(cx,cy,z);\n  ctx.save();\n  ctx.setTransform(dpr*th\/K, dpr*th\/2\/K, -dpr*th\/K, dpr*th\/2\/K, dpr*p[0], dpr*p[1]);\n  ctx.font=`700 ${K}px \"Chakra Petch\", sans-serif`;\n  let best=null;\n  for(const ls of opts){ const mw=Math.max(...ls.map(l=>ctx.measureText(l).width))\/K; const s=Math.min(maxW\/mw, maxH\/(ls.length*1.1)); if(!best||s>best.s) best={ls,s}; }\n  const s=Math.min(best.s,.5);\n  ctx.font=`700 ${K*s}px \"Chakra Petch\", sans-serif`;\n  const lum=(.299*c[0]+.587*c[1]+.114*c[2])*1.12;\n  ctx.fillStyle = lum>140 ? 'rgba(10,15,40,.9)' : 'rgba(240,244,255,.96)';\n  ctx.textAlign='center'; ctx.textBaseline='middle';\n  const lh=K*s*1.08, y0=-(best.ls.length-1)*lh\/2;\n  best.ls.forEach((l,i)=>ctx.fillText(l,0,y0+i*lh));\n  ctx.restore();\n}\nfunction cloud(x,y,s,dark){\n  ctx.fillStyle = dark ? 'rgba(70,74,105,.95)' : 'rgba(190,198,230,.85)';\n  ctx.beginPath();\n  ctx.arc(x-s*.5,y,s*.42,0,7); ctx.arc(x,y-s*.22,s*.55,0,7); ctx.arc(x+s*.55,y,s*.4,0,7);\n  ctx.fill();\n}\n\nconst markers = [];\nfunction drawWorld(){\n  const n = state.night, t = state.time;\n  const g = ctx.createLinearGradient(0,0,0,H);\n  g.addColorStop(0, str(lerpA(SKY.dTop,SKY.nTop,n)));\n  g.addColorStop(1, str(lerpA(SKY.dBot,SKY.nBot,n)));\n  ctx.fillStyle=g; ctx.fillRect(0,0,W,H);\n  if(n>.05){\n    for(const [sx,sy,ph] of STARS){\n      ctx.fillStyle=`rgba(220,230,255,${n*(.25+.5*(.5+.5*Math.sin(t*1.3+ph)))})`;\n      ctx.fillRect(sx*W, sy*H, 1.5, 1.5);\n    }\n  }\n  poly([iso(0,N),iso(N,N),iso(N,N,-.7),iso(0,N,-.7)], str(lerpA([30,40,80],[12,16,44],n)));\n  poly([iso(N,0),iso(N,N),iso(N,N,-.7),iso(N,0,-.7)], str(lerpA([44,58,104],[18,24,60],n)));\n  for(let y=0;y<N;y++) for(let x=0;x<N;x++){\n    const road = ROADX.includes(x)||ROADY.includes(y);\n    const day = road ? GROUND.dR : ((x+y)%2 ? GROUND.d1 : GROUND.d2);\n    const night = road ? GROUND.nR : ((x+y)%2 ? GROUND.n1 : GROUND.n2);\n    poly([iso(x,y),iso(x+1,y),iso(x+1,y+1),iso(x,y+1)], str(lerpA(day,night,n)), 'rgba(255,255,255,.035)');\n  }\n  ctx.save(); ctx.setLineDash([th*.25,th*.3]); ctx.lineWidth=1; ctx.strokeStyle=`rgba(255,255,255,${.22-.1*n})`;\n  for(const [[a,b],[c,d]] of ROADSEGS){ const p=iso(a,b), q=iso(c,d); ctx.beginPath(); ctx.moveTo(p[0],p[1]); ctx.lineTo(q[0],q[1]); ctx.stroke(); }\n  ctx.restore();\n\n  if(state.eccGone) drawPark();\n\n  if(n>.02){\n    for(const l of LINES){\n      const st = lineStatus(l);\n      const col = st==='crit' ? C.crit : l.color;\n      ctx.save(); ctx.globalAlpha = n*(st==='crit' ? (.5+.5*Math.sin(t*8)) : .85);\n      ctx.strokeStyle=col; ctx.lineWidth=Math.max(2,th*.12); ctx.lineCap='round'; ctx.lineJoin='round';\n      ctx.shadowColor=col; ctx.shadowBlur=reduce?0:10;\n      ctx.beginPath(); l.pts.forEach((p,i)=>{ const s=iso(p[0],p[1]); i?ctx.lineTo(s[0],s[1]):ctx.moveTo(s[0],s[1]); }); ctx.stroke();\n      ctx.restore();\n    }\n    ctx.save(); ctx.globalAlpha=n*.8;\n    for(const p of TRAFFIC){\n      const [[a,b],[c,d]] = ROADSEGS[p.seg];\n      const L = Math.hypot(c-a,d-b), f=p.u\/L;\n      const gx = a+(c-a)*f + (p.seg<2?p.side:0), gy = b+(d-b)*f + (p.seg>=2?p.side:0);\n      const s = iso(gx,gy);\n      ctx.fillStyle = 'rgba(200,240,255,.9)'; ctx.fillRect(s[0]-1,s[1]-1,2,2);\n    }\n    ctx.restore();\n    for(const l of LINES){\n      const st = lineStatus(l);\n      for(const tr of l.trains){\n        for(let k=0;k<3;k++){\n          let u = (tr.u - k*.28 + 2*l.len) % (2*l.len);\n          const s = u<l.len ? u : 2*l.len-u;\n          const g2 = pointAt(l.pts, s), sp = iso(g2[0],g2[1], .08);\n          ctx.fillStyle = st==='crit' ? C.crit : '#ffffff';\n          ctx.globalAlpha = n;\n          ctx.beginPath(); ctx.arc(sp[0],sp[1], Math.max(2,th*(k?.13:.17)),0,7); ctx.fill();\n          ctx.globalAlpha = 1;\n        }\n      }\n    }\n  }\n\n  for(const tr of state.trucks){\n    if(tr.u<0) continue;\n    const g2 = pointAt(tr.path, tr.u);\n    drawBox(g2[0]-.18,g2[1]-.18,.36,.36,.3, hexA(C.gold));\n  }\n\n  for(const b of ORDER) drawBuilding(b);\n\n  const alm = BLD.alm;\n  const tz = alm.h + alm.tower*Math.max(.1,alm.p);\n  const tip = iso(alm.x+1, alm.y+1, tz+.15);\n  if(n>.1){\n    const open = state.incidents.filter(i=>i.open);\n    const targets = open.length ? [...new Set(open.map(i=>i.b))] : ['dev','qas','prd','btp','cpi','sf','ariba'];\n    const tid = targets[state.beamIdx % targets.length];\n    const tb = BLD[tid]; if(tb){\n      const tp = iso(tb.x+tb.w\/2, tb.y+tb.d\/2, tb.top+.2);\n      const col = open.length ? C.crit : C.cyan;\n      const gr = ctx.createLinearGradient(tip[0],tip[1],tp[0],tp[1]);\n      gr.addColorStop(0, col); gr.addColorStop(1, 'rgba(79,216,255,0)');\n      ctx.save(); ctx.globalAlpha = n*.8; ctx.strokeStyle=gr; ctx.lineWidth=Math.max(2,th*.14);\n      ctx.beginPath(); ctx.moveTo(tip[0],tip[1]); ctx.lineTo(tp[0],tp[1]); ctx.stroke();\n      ctx.strokeStyle=col; ctx.lineWidth=1.5; ctx.globalAlpha = n*(1-(state.beamT%1.6)\/1.6);\n      const ring = iso(tb.x+tb.w\/2,tb.y+tb.d\/2,0);\n      ctx.beginPath(); ctx.ellipse(ring[0],ring[1], th*1.4*((state.beamT%1.6)\/1.6+.3), th*.7*((state.beamT%1.6)\/1.6+.3),0,0,7); ctx.stroke();\n      ctx.restore();\n    }\n  }\n  ctx.save(); ctx.fillStyle=C.cyan; ctx.shadowColor=C.cyan; ctx.shadowBlur=reduce?0:16;\n  ctx.globalAlpha=.6+.4*Math.sin(t*3);\n  ctx.beginPath(); ctx.arc(tip[0],tip[1],Math.max(3,th*.18),0,7); ctx.fill(); ctx.restore();\n\n  markers.length = 0;\n  const q = curQuest();\n  for(const b of ORDER){\n    if(b.id==='ecc' && state.eccGone) continue;\n    const cx=b.x+b.w\/2, cy=b.y+b.d\/2, top=b.top;\n    const sev = worstSev(b.id);\n    if(n>.3 && sev){\n      const cp = iso(cx,cy,top+1.5);\n      if(sev==='crit'){\n        ctx.save(); ctx.strokeStyle='rgba(140,170,255,.55)'; ctx.lineWidth=1;\n        for(let r=0;r<7;r++){ const rx=cp[0]-th*.8+((r*37+t*60)%(th*1.6)), ry=cp[1]+th*.3+((r*23+t*140)%(th*1.4)); ctx.beginPath(); ctx.moveTo(rx,ry); ctx.lineTo(rx-2,ry+6); ctx.stroke(); }\n        ctx.restore();\n        if(b.flash>0){ ctx.save(); ctx.strokeStyle='#fff7c2'; ctx.lineWidth=2; ctx.globalAlpha=b.flash; ctx.beginPath(); ctx.moveTo(cp[0],cp[1]); ctx.lineTo(cp[0]-5,cp[1]+th*.6); ctx.lineTo(cp[0]+3,cp[1]+th*.6); ctx.lineTo(cp[0]-3,cp[1]+th*1.3); ctx.stroke(); ctx.restore(); }\n      }\n      cloud(cp[0],cp[1], th*.9, sev==='crit');\n    }\n    if(state.mode==='build' && q && q.b===b.id && !state.golive){\n      const bob = reduce?0:Math.sin(t*3)*th*.15;\n      const mp = iso(cx,cy,top+1.1); mp[1]+=bob;\n      const s = Math.max(9, th*.42);\n      const isBad = state.defect===b.id;\n      ctx.save(); ctx.translate(mp[0],mp[1]); ctx.rotate(Math.PI\/4);\n      ctx.fillStyle = isBad ? C.crit : C.gold; ctx.shadowColor = isBad ? C.crit : C.gold; ctx.shadowBlur = reduce?0:14;\n      ctx.fillRect(-s\/2,-s\/2,s,s); ctx.restore();\n      ctx.fillStyle='#1a1205'; ctx.font=`700 ${s*1.05}px \"Chakra Petch\", sans-serif`; ctx.textAlign='center'; ctx.textBaseline='middle';\n      ctx.fillText('!', mp[0], mp[1]+1);\n      markers.push({id:b.id, x:mp[0], y:mp[1], r:s});\n    }\n    const inc = state.incidents.find(i=>i.open && i.b===b.id);\n    if(state.mode==='run' && inc){\n      const mp = iso(cx,cy,top+.7);\n      const s = Math.max(8, th*.36), col = inc.sev==='crit'?C.crit:C.warn;\n      ctx.save(); ctx.strokeStyle=col; ctx.lineWidth=2; ctx.globalAlpha=.5+.5*Math.sin(t*6);\n      ctx.beginPath(); ctx.arc(mp[0],mp[1], s*1.5,0,7); ctx.stroke(); ctx.restore();\n      ctx.fillStyle=col; ctx.beginPath(); ctx.arc(mp[0],mp[1],s,0,7); ctx.fill();\n      ctx.fillStyle='#160a0c'; ctx.font=`700 ${s*1.3}px \"Chakra Petch\", sans-serif`; ctx.textAlign='center'; ctx.textBaseline='middle';\n      ctx.fillText('!', mp[0], mp[1]+1);\n      markers.push({id:b.id, x:mp[0], y:mp[1], r:s*1.5});\n    }\n  }\n\n  for(const p of state.parts){\n    ctx.globalAlpha = Math.max(0,p.life\/p.max);\n    ctx.fillStyle = p.c; ctx.fillRect(p.x-p.s\/2,p.y-p.s\/2,p.s,p.s);\n  }\n  ctx.globalAlpha = 1;\n}\n\nfunction drawBuilding(b){\n  if(b.id==='ecc' && state.eccGone){ b.hull=null; b.top=0; return; }\n  const n = state.night;\n  let c = lerpA(b.c, lerpA(b.c, NAVY, .45), n);\n  if(b.id==='ecc') c = lerpA(c, [150,140,130], state.eccDemo*.6);\n  if(state.defect===b.id) c = lerpA(c, hexA(C.crit), .45+.35*Math.sin(state.time*8));\n  const sev = worstSev(b.id);\n  if(state.mode==='run' && sev==='crit') c = lerpA(c, hexA(C.crit), .18);\n  const x=b.x+.12, y=b.y+.12, w=b.w-.24, d=b.d-.24;\n  const p = b.id==='ecc' ? 1 : b.p;\n  const winColor = b.id==='alm' ? hexA(C.cyan) : hexA('#ffd68c');\n  let hull, top;\n  if(b.id==='alm'){\n    hull = drawBox(x,y,w,d,b.h,c,0,{seed:9,color:winColor});\n    topLabel('Cloud ALM', b.x+1, b.y+1.63, b.h, 1.5, .34, c);\n    const th2 = b.tower*Math.max(.1,p);\n    drawBox(b.x+.65,b.y+.65,.7,.7,th2, lerpA(c,[255,255,255],.1), b.h, {seed:11,color:winColor});\n    top = b.h+th2;\n    if(p<.995 &#038;&#038; state.night<.5) drawScaffoldTower(b, top);\n    const big = [iso(x,y,top),iso(x+w,y,top),iso(x+w,y,0),iso(x+w,y+d,0),iso(x,y+d,0),iso(x,y+d,top)];\n    hull = big;\n  } else {\n    const hh = b.id==='ecc' ? b.h*(1-state.eccDemo) : b.h*Math.max(.07,p);\n    hull = drawBox(x,y,w,d,Math.max(.02,hh),c,0,{seed:x*7+y*3,color:winColor});\n    top = hh;\n    topLabel(b.id==='sf'?'Success|Factors':b.name, b.x+1, b.y+1, Math.max(.02,hh), 1.55, 1.3, c);\n    if(b.id!=='ecc' &#038;&#038; p<.995 &#038;&#038; state.night<.5){ drawScaffold(x,y,w,d,b.h,hh); top=b.h; }\n  }\n  b.top = top; b.hull = hull;\n  if(state.hover===b.id) poly(hull, null, 'rgba(255,181,71,.9)', 1.5);\n}\nfunction drawScaffoldTower(b, cur){\n  const full = b.h + b.tower;\n  const p0 = iso(b.x+1,b.y+1,cur), p1 = iso(b.x+1,b.y+1,full);\n  ctx.save(); ctx.setLineDash([3,3]); ctx.strokeStyle='rgba(255,181,71,.6)'; ctx.lineWidth=1;\n  ctx.beginPath(); ctx.moveTo(p0[0]-th*.7,p0[1]); ctx.lineTo(p1[0]-th*.7,p1[1]); ctx.moveTo(p0[0]+th*.7,p0[1]); ctx.lineTo(p1[0]+th*.7,p1[1]); ctx.stroke(); ctx.restore();\n}\nfunction drawPark(){\n  const n = state.night;\n  poly([iso(10.1,6.1),iso(11.9,6.1),iso(11.9,7.9),iso(10.1,7.9)], str(lerpA([70,150,110],[24,70,58],n)));\n  topLabel('Clean Core', 11, 7.72, .01, 1.5, .3, [24,70,58]);\n  const trees = [[10.5,6.5],[11.4,6.6],[10.7,7.4],[11.5,7.5],[11,7]];\n  for(const [tx,ty] of trees){\n    const base = iso(tx,ty,0), top = iso(tx,ty,.5);\n    ctx.strokeStyle='#5b3d2a'; ctx.lineWidth=2; ctx.beginPath(); ctx.moveTo(base[0],base[1]); ctx.lineTo(top[0],top[1]); ctx.stroke();\n    ctx.fillStyle=str(lerpA([90,200,130],[40,120,90],n)); ctx.beginPath(); ctx.arc(top[0],top[1]-th*.12,th*.28,0,7); ctx.fill();\n  }\n}\n\nconst curQuest = () => (PHASES[state.phase] && PHASES[state.phase].quests[state.quest]) || null;\nfunction worstSev(id){\n  let s=null; for(const i of state.incidents){ if(i.open && i.b===id){ if(i.sev==='crit') return 'crit'; s='warn'; } } return s;\n}\nfunction lineStatus(l){ let s=null; for(const id of l.bldgs){ const v=worstSev(id); if(v==='crit') return 'crit'; if(v) s='warn'; } return s; }\nfunction anchor(id){ const b=BLD[id]; return iso(b.x+b.w\/2, b.y+b.d\/2, (b.top||1)); }\nfunction burst(id, color, n=24, up=1){\n  const [x,y] = anchor(id);\n  for(let i=0;i<(reduce?Math.ceil(n\/3):n);i++){\n    const a=Math.random()*Math.PI*2, v=40+Math.random()*120;\n    state.parts.push({x,y,vx:Math.cos(a)*v,vy:Math.sin(a)*v*.6-60*up,life:.9+Math.random()*.5,max:1.4,c:color,s:2+Math.random()*3,g:140});\n  }\n}\nfunction clock(){ return new Date().toTimeString().slice(0,8); }\nfunction addLog(txt, kind=''){ state.log.unshift({t:clock(),txt,kind}); state.log.length=Math.min(state.log.length,8); }\n\nfunction completeQuest(){\n  const q = curQuest(); if(!q || state.mode!=='build' || state.golive) return;\n  const b = BLD[q.b];\n  if(q.failFirst &#038;&#038; !q.failed){\n    q.failed = true; state.defect = q.b;\n    addLog('Test Management \u00b7 3 cas de test KO sur 18', 'crit');\n    toast('Test en \u00e9chec', '3 cas KO sur 18 sur '+b.name+'. Corrigez le d\u00e9faut puis relancez.', 'crit');\n    burst(q.b, C.crit, 26);\n    renderPanel(); return;\n  }\n  if(q.failed){ state.defect=null; addLog('D\u00e9faut corrig\u00e9, re-test OK', 'ok'); }\n  if(q.gain) for(const k in q.gain) BLD[k].tp = Math.min(1, BLD[k].tp + q.gain[k]);\n  if(q.trucks) spawnTrucks();\n  if(q.demolish){ state.eccDemoing = true; }\n  state.xp += 100; q.done = true;\n  burst(q.b, C.gold, 30);\n  addLog(q.app+' \u00b7 '+q.t, 'ok');\n  toast('+100 XP', q.t, 'gold');\n  if(q.golive){ goLive(); return; }\n  state.quest++;\n  if(state.quest >= PHASES[state.phase].quests.length){\n    const done = PHASES[state.phase].name;\n    state.phase++; state.quest = 0;\n    showBanner('Phase termin\u00e9e', done, 'Prochaine \u00e9tape : '+PHASES[state.phase].name);\n  }\n  renderPanel();\n}\nfunction goLive(){\n  state.golive = true; state.defect = null;\n  for(const k in BLD) BLD[k].tp = 1;\n  state.eccDemoing = true;\n  state.phase = 4; state.quest = 0; state.xp = TOTAL_XP;\n  for(const k in BLD) if(k!=='ecc') burst(k, C.gold, 18);\n  addLog('Go-live : Nov\u00e9a tourne sur S\/4HANA', 'ok');\n  showBanner('Succ\u00e8s d\u00e9bloqu\u00e9', 'Go-live', 'Nov\u00e9a tourne sur S\/4HANA. Le mode RUN est ouvert.', 3600);\n  renderModes(); renderPanel();\n}\nfunction skipToGoLive(){\n  for(const ph of PHASES) for(const q of ph.quests){\n    if(!q.done){ if(q.gain) for(const k in q.gain) BLD[k].tp=Math.min(1,BLD[k].tp+q.gain[k]); q.done=true; if(q.failFirst) q.failed=true; }\n  }\n  state.eccDemo = 1; state.eccGone = true;\n  goLive();\n}\nfunction spawnTrucks(){\n  const path = [[5.5,3],[5.5,7],[5.5,11]];\n  for(let i=0;i<3;i++) state.trucks.push({path, u:-i*.9, L:pathLen(path)});\n}\nfunction setMode(m){\n  if(m==='run' &#038;&#038; !state.golive) return;\n  if(m===state.mode) return;\n  state.mode = m; state.hover = null; $('#tip').hidden = true;\n  if(m==='run'){\n    state.nextSpawn = 2.5;\n    if(!state.ach.has('night')){ state.ach.add('night'); showBanner('Mode RUN', 'La ville s\u2019allume', 'Cloud ALM surveille maintenant processus, interfaces, jobs et utilisateurs.', 3200); }\n  }\n  renderModes(); renderPanel();\n}\nfunction spawnIncident(){\n  const busy = new Set(state.incidents.filter(i=>i.open).map(i=>i.b));\n  const pool = INC.filter(x=>!busy.has(x.b));\n  if(!pool.length) return;\n  const tpl = pool[Math.floor(Math.random()*pool.length)];\n  const inc = {...tpl, id:++incSeq, t0:state.time, open:true};\n  state.incidents.push(inc);\n  addLog(tpl.app+' \u00b7 '+tpl.title, tpl.sev);\n  toast(tpl.sev==='crit'?'Alerte critique':'Alerte', tpl.title+' \u00b7 '+BLD[tpl.b].name, tpl.sev==='crit'?'crit':'gold');\n  if(!state.sel) state.sel = inc.id;\n  renderPanel();\n}\nfunction resolve(id){\n  const inc = state.incidents.find(i=>i.id===id && i.open); if(!inc) return;\n  inc.open = false;\n  const dur = state.time - inc.t0;\n  state.mttr.push(dur); state.resolved++;\n  burst(inc.b, C.ok, 34); burst(inc.b, C.cyan, 16);\n  addLog('R\u00e9solu en '+Math.round(dur)+' s \u00b7 '+inc.title, 'ok');\n  toast('Incident r\u00e9solu', inc.action+' \u00b7 '+Math.round(dur)+' s', 'ok');\n  if(state.sel===id){ const next = state.incidents.find(i=>i.open); state.sel = next?next.id:null; }\n  if(state.resolved===1 && !state.ach.has('first')){ state.ach.add('first'); showBanner('Succ\u00e8s d\u00e9bloqu\u00e9','Premier incident ma\u00eetris\u00e9','D\u00e9tect\u00e9, diagnostiqu\u00e9 et corrig\u00e9 depuis Cloud ALM.'); }\n  else if(dur<6 &#038;&#038; !state.ach.has('fast')){ state.ach.add('fast'); showBanner('Succ\u00e8s d\u00e9bloqu\u00e9','R\u00e9flexe \u00e9clair','Incident r\u00e9solu en moins de 6 secondes.'); }\n  else if(state.resolved===5 &#038;&#038; !state.ach.has('five')){ state.ach.add('five'); showBanner('Succ\u00e8s d\u00e9bloqu\u00e9','\u00c9quipe de garde','5 incidents r\u00e9solus. Les processus restent fluides.'); }\n  if(state.nextSpawn<3) state.nextSpawn = 3;\n  renderPanel();\n}\n\nfunction update(dt){\n  state.time += dt;\n  state.night += ((state.mode==='run'?1:0)-state.night)*Math.min(1,dt*1.5);\n  for(const k in BLD){ const b=BLD[k]; b.p += (b.tp-b.p)*Math.min(1,dt*2.2); b.flash=Math.max(0,b.flash-dt*4); }\n  if(state.eccDemoing &#038;&#038; !state.eccGone){\n    state.eccDemo = Math.min(1, state.eccDemo + dt*.55);\n    if(Math.random()<dt*40){ const p=iso(10.2+Math.random()*1.6, 6.2+Math.random()*1.6, 0); state.parts.push({x:p[0],y:p[1],vx:(Math.random()-.5)*30,vy:-20-Math.random()*40,life:1,max:1,c:'rgba(200,190,180,.8)',s:3+Math.random()*4,g:-10}); }\n    if(state.eccDemo>=1){ state.eccGone=true; addLog('ECC d\u00e9commissionn\u00e9. Le terrain devient le parc Clean Core','ok'); const p=iso(11,7,0); for(let i=0;i<30;i++){const a=Math.random()*6.28; state.parts.push({x:p[0],y:p[1],vx:Math.cos(a)*90,vy:Math.sin(a)*50-50,life:1,max:1,c:C.ok,s:3,g:120});} }\n  }\n  for(const tr of state.trucks){ tr.u += dt*2.4; if(tr.u>=tr.L){ tr.done=true; burst('prd', C.gold, 12); } }\n  state.trucks = state.trucks.filter(t=>!t.done);\n  if(state.mode==='run'){\n    state.uptime += dt;\n    state.beamT += dt; if(state.beamT>1.6){ state.beamT=0; state.beamIdx++; }\n    const open = state.incidents.filter(i=>i.open);\n    state.nextSpawn -= dt;\n    if(state.nextSpawn<=0){ if(open.length<3) spawnIncident(); state.nextSpawn = 8+Math.random()*5; }\n    const crits = open.filter(i=>i.sev==='crit').length;\n    state.avail = crits ? Math.max(99.2, state.avail - .006*crits*dt) : Math.min(99.97, state.avail + .002*dt);\n    for(const l of LINES){\n      const st = lineStatus(l), sp = st==='crit'?0:st==='warn'?.6:1.7;\n      for(const tr of l.trains) tr.u = (tr.u + sp*dt) % (2*l.len);\n    }\n    for(const p of TRAFFIC){ const [[a,b],[c,d]]=ROADSEGS[p.seg]; const L=Math.hypot(c-a,d-b); p.u += p.dir*p.sp*dt; if(p.u<0||p.u>L){ p.dir*=-1; p.u=Math.max(0,Math.min(L,p.u)); } }\n    for(const i of open){\n      const b = BLD[i.b];\n      if(i.sev==='crit'){\n        if(!reduce && Math.random()<dt*22){ const p=iso(b.x+.4+Math.random()*1.2, b.y+.4+Math.random()*1.2, b.top); state.parts.push({x:p[0],y:p[1],vx:(Math.random()-.5)*14,vy:-30-Math.random()*40,life:.7,max:.7,c:Math.random()<.5?'#ff7a3d':'#ffcf4a',s:2+Math.random()*3,g:-20}); }\n        if(Math.random()<dt*.5) b.flash = 1;\n      }\n    }\n  }\n  for(const p of state.parts){ p.life-=dt; p.vy+=p.g*dt; p.x+=p.vx*dt; p.y+=p.vy*dt; }\n  state.parts = state.parts.filter(p=>p.life>0);\n  if(state.parts.length>600) state.parts.splice(0,state.parts.length-600);\n}\n\nconst panel = $('#panel');\nfunction fmtDur(s){ s=Math.floor(s); const h=String(Math.floor(s\/3600)).padStart(2,'0'), m=String(Math.floor(s\/60)%60).padStart(2,'0'), ss=String(s%60).padStart(2,'0'); return h+':'+m+':'+ss; }\nfunction renderModes(){\n  const mb=$('#mBuild'), mr=$('#mRun');\n  mb.setAttribute('aria-pressed', state.mode==='build');\n  mr.setAttribute('aria-pressed', state.mode==='run');\n  mr.disabled = !state.golive;\n  mr.querySelector('small').textContent = state.golive ? 'Op\u00e9rations' : 'D\u00e9bloqu\u00e9 au go-live';\n  mr.classList.toggle('ready', state.golive && state.mode==='build');\n}\nfunction renderHud(){\n  const hud = $('#hud');\n  if(state.mode==='build'){\n    const ph = PHASES[state.phase];\n    hud.innerHTML = `<span class=\"chip gold\">SAP Activate \u00b7 ${state.golive?'Go-live atteint':'Phase '+ph.name}<\/span><span class=\"chip\">${Math.round(state.xp\/TOTAL_XP*100)} % du projet<\/span>`;\n  } else {\n    const open = state.incidents.filter(i=>i.open).length;\n    hud.innerHTML = `<span class=\"chip cyan\">PRD en production \u00b7 ${fmtDur(state.uptime)}<\/span><span class=\"chip\">${open} alerte${open>1?'s':''} ouverte${open>1?'s':''}<\/span>`;\n  }\n}\nfunction renderLegend(){\n  $('#legend').innerHTML = state.mode==='build'\n    ? `<span><i class=\"q\"><\/i>Qu\u00eate active<\/span><span><i style=\"background:var(--gold)\"><\/i>Grue : en construction<\/span><span><i style=\"background:var(--crit)\"><\/i>Rouge : d\u00e9faut de test<\/span>`\n    : LINES.map(l=>`<span><i style=\"background:${l.color}\"><\/i>${l.name}<\/span>`).join('') + `<span><i style=\"background:var(--muted)\"><\/i>Nuage : d\u00e9grad\u00e9<\/span><span><i style=\"background:var(--crit)\"><\/i>Orage : critique<\/span>`;\n}\nconst esc = s => String(s).replace(\/[&<>\"]\/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;'}[c]));\nfunction renderPanel(){\n  panel.innerHTML = state.mode==='build' ? buildPanel() : runPanel();\n  renderHud(); renderLegend(); updateLive();\n}\nfunction buildPanel(){\n  const q = curQuest(), pct = Math.round(state.xp\/TOTAL_XP*100);\n  const phases = PHASES.map((p,i)=>`<li class=\"${i<state.phase?'done':i===state.phase?'cur':''}\">${p.name}<\/li>`).join('');\n  let card;\n  if(state.golive){\n    card = `<div class=\"quest gl\"><p class=\"eyebrow\">Campagne termin\u00e9e<\/p><h4>Go-live atteint<\/h4><p>La ville est construite. Passez en mode RUN pour l\u2019exploiter avec Cloud ALM.<\/p><button class=\"btn cyan\" data-act=\"run\">Passer en mode RUN<\/button><\/div>`;\n  } else {\n    const bad = state.defect===q.b;\n    card = `<div class=\"quest ${bad?'bad':''}\"><p class=\"eyebrow\">${esc(q.app)}<\/p><h4>${esc(q.t)}<\/h4>\n      <p class=\"hint\">${bad ? 'D\u00e9faut d\u00e9tect\u00e9 sur '+esc(BLD[q.b].name)+'. Corrigez-le pour poursuivre.' : 'Cliquez sur le b\u00e2timent marqu\u00e9 <b>!<\/b> ('+esc(BLD[q.b].name)+') ou lancez la qu\u00eate ici.'}<\/p>\n      <button class=\"btn primary\" data-act=\"quest\">${bad?'Corriger le d\u00e9faut':'Lancer la qu\u00eate'}<\/button><\/div>`;\n  }\n  const camp = PHASES.slice(0,4).map(p=>`<div><p class=\"qg-name\">${p.name}<\/p><ul>${p.quests.map(x=>`<li class=\"${x.done?'done':x===q?'cur':''}\"><i><\/i><span>${esc(x.t)}<\/span><em>${esc(x.app)}<\/em><\/li>`).join('')}<\/ul><\/div>`).join('');\n  return `<ol class=\"phases\" aria-label=\"Phases SAP Activate\">${phases}<\/ol>\n    <div><div class=\"xp-row\"><b>Niveau ${Math.min(state.phase+1,5)} \u00b7 ${PHASES[Math.min(state.phase,4)].name}<\/b><span>${state.xp} \/ ${TOTAL_XP} XP<\/span><\/div><div class=\"xp-bar\"><i style=\"width:${pct}%\"><\/i><\/div><\/div>\n    <section><h3>Qu\u00eate en cours<\/h3>${card}<\/section>\n    <section><h3>Campagne RISE with SAP<\/h3><div class=\"campaign\">${camp}<\/div><\/section>\n    ${state.golive?'':'<div class=\"foot-actions\"><button class=\"btn ghost\" data-act=\"skip\">Sauter au go-live<\/button><\/div>'}`;\n}\nfunction runPanel(){\n  const open = state.incidents.filter(i=>i.open).sort((a,b)=>(a.sev==='crit'?0:1)-(b.sev==='crit'?0:1) || a.t0-b.t0);\n  const alerts = open.length ? `<ul class=\"alerts\">${open.map(i=>{\n      const on = state.sel===i.id;\n      return `<li class=\"alert ${i.sev}\"><button class=\"a-head\" data-act=\"sel\" data-id=\"${i.id}\" aria-expanded=\"${on}\"><span class=\"sev\">${i.sev==='crit'?'Critique':'Alerte'}<\/span><span class=\"a-t\">${esc(i.title)}<\/span><span class=\"age\" data-age=\"${i.id}\"><\/span><\/button>\n      ${on?`<div class=\"a-body\"><p class=\"app\">${esc(i.app)}<\/p><p>${esc(i.cause)}<\/p><p>B\u00e2timent : ${esc(BLD[i.b].name)}<\/p><button class=\"btn primary\" data-act=\"fix\" data-id=\"${i.id}\">${esc(i.action)}<\/button><\/div>`:''}<\/li>`;}).join('')}<\/ul>`\n    : `<p class=\"empty\">Aucune alerte ouverte. La ville tourne. Les prochains signaux arrivent d\u2019eux-m\u00eames.<\/p>`;\n  const log = state.log.map(l=>`<li><time>${l.t}<\/time><span class=\"${l.kind}\">${esc(l.txt)}<\/span><\/li>`).join('');\n  return `<div class=\"kpis\">\n      <div class=\"kpi\"><span>Disponibilit\u00e9 PRD<\/span><b data-k=\"avail\"><\/b><small>simul\u00e9e<\/small><\/div>\n      <div class=\"kpi\"><span>Alertes ouvertes<\/span><b data-k=\"open\"><\/b><small>en temps r\u00e9el<\/small><\/div>\n      <div class=\"kpi\"><span>Temps moyen de r\u00e9solution<\/span><b data-k=\"mttr\"><\/b><small>MTTR<\/small><\/div>\n      <div class=\"kpi\"><span>Incidents r\u00e9solus<\/span><b data-k=\"res\"><\/b><small>depuis le go-live<\/small><\/div>\n    <\/div>\n    <section><h3>Processus m\u00e9tier<\/h3><ul class=\"lines\">${LINES.map(l=>`<li><i style=\"background:${l.color}\"><\/i><span>${l.name}<\/span><b class=\"st\" data-line=\"${l.id}\"><\/b><\/li>`).join('')}<\/ul><\/section>\n    <section><h3>Alertes Cloud ALM<\/h3>${alerts}<\/section>\n    <section><h3>Journal<\/h3><ol class=\"log\">${log}<\/ol><\/section>\n    <div class=\"foot-actions\"><button class=\"btn ghost\" data-act=\"spawn\">D\u00e9clencher un incident<\/button><button class=\"btn ghost\" data-act=\"build\">Revoir le chantier<\/button><\/div>`;\n}\nfunction updateLive(){\n  renderHud();\n  if(state.mode!=='run') return;\n  const open = state.incidents.filter(i=>i.open);\n  const set = (k,v)=>{ const el=panel.querySelector(`[data-k=\"${k}\"]`); if(el) el.textContent=v; };\n  set('avail', state.avail.toFixed(2).replace('.',',')+' %');\n  set('open', String(open.length));\n  set('mttr', state.mttr.length ? Math.round(state.mttr.reduce((a,b)=>a+b,0)\/state.mttr.length)+' s' : 'n\/a');\n  set('res', String(state.resolved));\n  for(const l of LINES){\n    const el = panel.querySelector(`[data-line=\"${l.id}\"]`); if(!el) continue;\n    const st = lineStatus(l);\n    el.className = 'st'+(st?' '+st:''); el.textContent = st==='crit'?'Bloqu\u00e9':st==='warn'?'Ralenti':'Fluide';\n  }\n  for(const i of open){ const el=panel.querySelector(`[data-age=\"${i.id}\"]`); if(el) el.textContent = Math.round(state.time-i.t0)+' s'; }\n}\npanel.addEventListener('click', e=>{\n  const btn = e.target.closest('[data-act]'); if(!btn) return;\n  const a = btn.dataset.act, id = +btn.dataset.id;\n  if(a==='quest') completeQuest();\n  else if(a==='skip') skipToGoLive();\n  else if(a==='run') setMode('run');\n  else if(a==='build') setMode('build');\n  else if(a==='spawn') spawnIncident();\n  else if(a==='sel'){ state.sel = state.sel===id ? null : id; renderPanel(); }\n  else if(a==='fix') resolve(id);\n});\n$('.modes').addEventListener('click', e=>{ const b=e.target.closest('[data-mode]'); if(b && !b.disabled) setMode(b.dataset.mode); });\n\nfunction toast(title, sub, kind='gold'){\n  const el = document.createElement('div');\n  el.className = 'toast '+(kind==='gold'?'':kind);\n  el.innerHTML = `<b>${esc(title)}<\/b><span>${esc(sub)}<\/span>`;\n  const box = $('#toasts'); box.prepend(el);\n  while(box.children.length>3) box.lastChild.remove();\n  setTimeout(()=>el.remove(), 3800);\n}\nlet bannerTimer;\nfunction showBanner(k,t,s,ms=2800){\n  const b=$('#banner');\n  b.querySelector('.k').textContent=k; b.querySelector('.t').textContent=t; b.querySelector('.s').textContent=s;\n  b.hidden=false; b.style.animation='none'; void b.offsetWidth; b.style.animation='';\n  clearTimeout(bannerTimer); bannerTimer=setTimeout(()=>{b.hidden=true;}, ms);\n}\n\nfunction pick(x,y){\n  for(const m of markers) if(Math.hypot(x-m.x,y-m.y) < m.r+6) return m.id;\n  for(let i=ORDER.length-1;i>=0;i--){ const b=ORDER[i]; if(b.hull && inPoly([x,y], b.hull)) return b.id; }\n  return null;\n}\nfunction tipFor(id, x, y){\n  const tip = $('#tip'); if(!id){ tip.hidden=true; return; }\n  const b = BLD[id]; let extra;\n  if(state.mode==='build'){\n    const q = curQuest();\n    if(id==='ecc') extra = state.eccDemoing ? 'D\u00e9commissionnement en cours' : 'Legacy, sera remplac\u00e9';\n    else extra = 'Construction '+Math.round(b.p*100)+' %';\n    if(q && q.b===id && !state.golive) extra += ' \u00b7 qu\u00eate active, cliquez';\n  } else {\n    const inc = state.incidents.find(i=>i.open && i.b===id);\n    extra = inc ? (inc.sev==='crit'?'Critique':'D\u00e9grad\u00e9')+' \u00b7 '+inc.title : 'Sain';\n  }\n  tip.innerHTML = `<b>${esc(b.name)}<\/b><span>${esc(b.sub)}<\/span><em>${esc(extra)}<\/em>`;\n  tip.hidden = false;\n  const r = cv.getBoundingClientRect();\n  const tw = tip.offsetWidth, thh = tip.offsetHeight;\n  tip.style.left = Math.min(r.width-tw-8, Math.max(8, x+14))+'px';\n  tip.style.top = Math.min(r.height-thh-8, Math.max(8, y+14))+'px';\n}\ncv.addEventListener('pointermove', e=>{\n  if(e.pointerType==='touch') return;\n  const id = pick(e.offsetX, e.offsetY);\n  if(id==='ecc' && state.eccGone){ state.hover=null; tipFor(null); cv.style.cursor='default'; return; }\n  state.hover = id; tipFor(id, e.offsetX, e.offsetY);\n  const clickable = id && ((state.mode==='build' && curQuest() && curQuest().b===id && !state.golive) || (state.mode==='run' && state.incidents.some(i=>i.open&&i.b===id)));\n  cv.style.cursor = clickable ? 'pointer' : 'default';\n});\ncv.addEventListener('pointerleave', ()=>{ state.hover=null; tipFor(null); });\ncv.addEventListener('click', e=>{\n  const id = pick(e.offsetX, e.offsetY);\n  if(!id || (id==='ecc' && state.eccGone)){ tipFor(null); return; }\n  if(state.mode==='build'){\n    const q = curQuest();\n    if(q && q.b===id && !state.golive){ completeQuest(); tipFor(null); return; }\n  } else {\n    const inc = state.incidents.find(i=>i.open && i.b===id);\n    if(inc){\n      state.sel = inc.id; renderPanel();\n      if(matchMedia('(max-width:880px)').matches) panel.scrollIntoView({behavior:reduce?'auto':'smooth', block:'start'});\n    }\n  }\n  tipFor(id, e.offsetX, e.offsetY);\n});\n\nlet last = performance.now(), liveAcc = 0;\nfunction frame(now){\n  const dt = Math.min(.05, (now-last)\/1000); last = now;\n  update(dt);\n  drawWorld();\n  liveAcc += dt; if(liveAcc>.5){ liveAcc=0; updateLive(); }\n  requestAnimationFrame(frame);\n}\nnew ResizeObserver(()=>{ resize(); drawWorld(); }).observe(cv.parentElement);\nresize();\naddLog('Projet RISE with SAP ouvert pour Nov\u00e9a Industries','');\nrenderModes(); renderPanel();\ndrawWorld();\nrequestAnimationFrame(frame);\nsetTimeout(()=>showBanner('Campagne RISE with SAP','Construisez Nov\u00e9a','Menez le projet de Prepare au go-live, puis exploitez la ville.', 3200), 400);\n})();\n<\\\/script>\n<\/body><\/html>\n<\/script>\n<script>(function(){var t=document.getElementById('hz-alm-src'),f=document.getElementById('hz-alm-frame');if(t&&f){f.srcdoc=t.textContent.replace(\/&#0?38;\/g,'&').replace('<\\\\\/script>','<\/'+'script>');}})();<\/script>\n\n\n\n<link rel='stylesheet' href='https:\/\/fonts.googleapis.com\/css2?family=Schibsted+Grotesk:wght@400;500;600;700&#038;family=IBM+Plex+Mono:wght@400;500&#038;display=swap'><div class='hzr' style='--hx:clamp(24px,4.5vw,64px);max-width:1440px;margin:48px auto 0;background:#FAF9F6;font-family:\"Schibsted Grotesk\",sans-serif;color:#141412'><footer class='hz-ft'><div><img src='https:\/\/www.hashdezo.fr\/wp-content\/uploads\/2020\/03\/Logo.jpg' alt='HASHdezO'><span class='t'>Conseil, r\u00e9alisation et formation SAP Cloud.<\/span><a href='https:\/\/support.sap.com\/en\/alm\/partners\/partner-powered-calm.html' target='_blank' rel='noopener' style='display:block;align-self:flex-start;margin-top:14px'><img src='https:\/\/www.hashdezo.fr\/wp-content\/uploads\/2026\/10\/powered-by-sap-cloud-alm.png' alt='Partner powered by SAP Cloud ALM' width='110' height='51' style='width:110px;height:auto;mix-blend-mode:normal;display:block'><\/a><\/div><div><span class='hz-m'>#HashALM<\/span><a href='\/index.php\/confluencealm\/'>Les 5 offres Cloud ALM<\/a><a href='\/index.php\/alm-city\/'>ALM City<\/a><a href='\/index.php\/alm-runner\/'>ALM Runner<\/a><\/div><div><span class='hz-m'>Expertises<\/span><a href='\/index.php\/sap-rise-with-sap\/'>Cloud ERP &amp; RISE<\/a><a href='\/index.php\/offres\/'>SAP BTP<\/a><a href='\/index.php\/sapbw4hana\/'>Data &amp; Analytics<\/a><\/div><div><span class='hz-m'>Cabinet<\/span><a href='\/index.php\/nous\/'>\u00c0 propos<\/a><a href='\/index.php\/bibliotheque-cloud-alm\/'>Biblioth\u00e8que<\/a><a href='\/index.php\/paroles-d-experts\/'>Paroles d'experts<\/a><a href='\/index.php\/contact\/'>Contact<\/a><\/div><div><span class='hz-m'>Contact<\/span><a href='mailto:contact@hashdezo.fr'>contact@hashdezo.fr<\/a><a href='tel:+33622616516'>+33 6 22 61 65 16<\/a><span>229 rue Saint-Honor\u00e9, 75001 Paris<\/span><\/div><\/footer><div class='hz-fb'><span class='hz-m'>Certifi\u00e9 RISE with SAP \u00b7 SAP Cloud ALM \u00b7 Architecte SAP BTP \u00b7 SAP Integration Suite<\/span><span>\u00a9 2026 HASHdezO \u00b7 <a href='\/index.php\/mentions-legales\/' style='color:inherit'>Mentions l\u00e9gales<\/a><\/span><\/div><\/div>\n","protected":false},"excerpt":{"rendered":"<p>Conseil, r\u00e9alisation et formation SAP Cloud. #HashALMLes 5 offres Cloud ALMALM CityALM Runner ExpertisesCloud ERP &amp; RISESAP BTPData &amp; Analytics Cabinet\u00c0 proposBiblioth\u00e8queParoles d&rsquo;expertsContact Contactcontact@hashdezo.fr+33 6 22 61 65 16229 rue Saint-Honor\u00e9, 75001 Paris Certifi\u00e9 RISE with SAP \u00b7 SAP Cloud ALM \u00b7 Architecte SAP BTP \u00b7 SAP Integration Suite\u00a9 2026 HASHdezO \u00b7 Mentions l\u00e9gales<\/p>\n","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"neve_meta_sidebar":"full-width","neve_meta_container":"full-width","neve_meta_enable_content_width":"","neve_meta_content_width":0,"neve_meta_title_alignment":"","neve_meta_author_avatar":"","neve_post_elements_order":"","neve_meta_disable_header":"","neve_meta_disable_footer":"on","neve_meta_disable_title":"on","_themeisle_gutenberg_block_has_review":false,"footnotes":""},"class_list":["post-1112","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/www.hashdezo.fr\/index.php\/wp-json\/wp\/v2\/pages\/1112","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.hashdezo.fr\/index.php\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/www.hashdezo.fr\/index.php\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/www.hashdezo.fr\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.hashdezo.fr\/index.php\/wp-json\/wp\/v2\/comments?post=1112"}],"version-history":[{"count":10,"href":"https:\/\/www.hashdezo.fr\/index.php\/wp-json\/wp\/v2\/pages\/1112\/revisions"}],"predecessor-version":[{"id":1139,"href":"https:\/\/www.hashdezo.fr\/index.php\/wp-json\/wp\/v2\/pages\/1112\/revisions\/1139"}],"wp:attachment":[{"href":"https:\/\/www.hashdezo.fr\/index.php\/wp-json\/wp\/v2\/media?parent=1112"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}