← 제니앱

비밀열차

정해진 순찰을 도는 경비를 피해 가방을 훔쳐 탈출하는 결정론 턴제 잠입 게임입니다. 같은 시드면 늘 같은 열차라, 실패해도 한 수만 바꾸면 됩니다.

SILENT CAR · TURN-BASED비밀열차

정해진 순찰을 피해 열쇠로 가방을 열어 훔치고 출구로 탈출합니다. 같은 시드면 늘 같은 열차입니다.

// ===== main.tpz =====
// 비밀열차 — 결정론 턴제 잠입. .tpz = 순수 스텝퍼(입력 패킷 → 다음 STATE + DRAW 리스트). JS 셸이 그린다. 한 이벤트 = 한 턴: 플레이어 행동 → 경비 한 칸 전진 → 발각 판정 → 놓침 판정.
// Secret-train: a deterministic turn-based stealth heist. Pure stepper. One event = one turn: player acts -> guards step -> detection -> left-behind check.

import core.state
import data.levels

let 기본시드 = "BT-0001"

// 닫힌 이벤트 태그. 원문 EVENT 토큰은 패킷 경계라 파싱 지점에서만 좁힌다.
// Closed event tags. The raw EVENT token is packet-boundary text and narrows only at parse time.
type 이벤트 = "init" | "resume" | "goL01" | "goL02" | "goL03" | "goL04" | "goL05" | "up" | "down" | "left" | "right" | "a" | "b" | "unknown"

function 이벤트파싱(토큰: string) -> 이벤트 {
  match 토큰.trim() {
    case "init" => "init"
    case "resume" => "resume"
    case "goL01" => "goL01"
    case "goL02" => "goL02"
    case "goL03" => "goL03"
    case "goL04" => "goL04"
    case "goL05" => "goL05"
    case "up" => "up"
    case "down" => "down"
    case "left" => "left"
    case "right" => "right"
    case "a" => "a"
    case "b" => "b"
    case _ => "unknown"
  }
}

// ── 입력 패킷 파싱 ── 접두사 길이만큼 잘라낸 나머지. str.slice(스칼라 인덱스)로 한 줄 — 이 메서드가 없어 예전엔 scalars()+배열 slice로 우회했었다(도그푸드로 추가). / prefix-strip via str.slice (added to Topaz after this app surfaced the gap).
function 접두사뒤(전체: string, 접두사: string) -> string {
  전체.slice(접두사.scalars().length, 전체.scalars().length)
}
function 줄값(입력: string, 접두: string) -> string {
  let mut 값 = ""
  for 줄 in 입력.split("\n") {
    let 다듬 = 줄.trim()
    if 다듬.startsWith(접두) { 값 = 접두사뒤(다듬, 접두) }
  }
  값.trim()
}
function 셸값(셸: string, 키: string) -> string {
  for 항 in 셸.split(",") {
    let kv = 항.split("=")
    if kv.length >= 2 && kv[0] == 키 { return kv[1] }
  }
  ""
}

// 방향 델타. 0상1우2하3좌. / facing delta.
function 델타엑스(방향: int) -> int { if 방향 == 1 { 1 } else if 방향 == 3 { -1 } else { 0 } }
function 델타와이(방향: int) -> int { if 방향 == 2 { 1 } else if 방향 == 0 { -1 } else { 0 } }

// 경비 한 명의 현재 순찰점(phase로 유도). / a guard's patrol point for a phase.
function 경비점(레벨: levels.레벨, 경비인덱스: int, phase: int) -> levels.순찰점 {
  let g = 레벨.경비들[경비인덱스]
  let mut p = phase
  if p < 0 { p = 0 }
  if p >= g.순찰.length { p = p % g.순찰.length }
  g.순찰[p]
}

// 시야: 경비 정면으로 직선 한 칸씩(시야거리까지), 벽·좌석에 막힘. 플레이어 칸에 닿으면 발각. / straight LOS ray; blocked by wall/seat; hits player -> spotted.
function 시야적중(레벨: levels.레벨, gx: int, gy: int, gd: int, px: int, py: int) -> bool {
  let mut x = gx
  let mut y = gy
  let mut k = 0
  while k < 레벨.시야거리 {
    x = x + 델타엑스(gd)
    y = y + 델타와이(gd)
    let t = levels.타일에서(레벨, x, y)
    if t == 1 || t == 2 { return false }
    if x == px && y == py { return true }
    k = k + 1
  }
  false
}

// 인접한 칸에 좌석이 있나(버티기 가능 지점). / is there a seat orthogonally adjacent (a brace point)?
function 인접좌석(레벨: levels.레벨, x: int, y: int) -> bool {
  levels.타일에서(레벨, x + 1, y) == 2 || levels.타일에서(레벨, x - 1, y) == 2 ||
  levels.타일에서(레벨, x, y + 1) == 2 || levels.타일에서(레벨, x, y - 1) == 2
}

// ── 한 턴 적용 ── 행동 → 흔들림(안 버티면 한 칸 밀림) → 경비 전진 → 발각(버티면 시야 면역) → 놓침. 모두 결정론.
function 적용(이벤트값: 이벤트, 직전: state.상태) -> state.상태 {
  if 직전.끝 != 0 { return 직전 }
  let 레벨 = levels.레벨얻기(직전.레벨아이디)

  let mut 엑스 = 직전.엑스
  let mut 와이 = 직전.와이
  let mut 방향 = 직전.방향
  let mut 열쇠 = 직전.열쇠있음
  let mut 가방 = 직전.가방있음
  let mut 숨음 = false
  let mut 메시지 = 0

  // 1) 플레이어 행동. 방향키=마주+이동(바닥만). a=열쇠/가방/탈출. b=버티기(인접 좌석이면 흔들림·시야 면역, 아니면 대기).
  match 이벤트값 {
    case "up" => {
      방향 = 0
      let nx = 엑스 + 델타엑스(방향)
      let ny = 와이 + 델타와이(방향)
      if levels.타일에서(레벨, nx, ny) == 0 { 엑스 = nx; 와이 = ny }
    }
    case "right" => {
      방향 = 1
      let nx = 엑스 + 델타엑스(방향)
      let ny = 와이 + 델타와이(방향)
      if levels.타일에서(레벨, nx, ny) == 0 { 엑스 = nx; 와이 = ny }
    }
    case "down" => {
      방향 = 2
      let nx = 엑스 + 델타엑스(방향)
      let ny = 와이 + 델타와이(방향)
      if levels.타일에서(레벨, nx, ny) == 0 { 엑스 = nx; 와이 = ny }
    }
    case "left" => {
      방향 = 3
      let nx = 엑스 + 델타엑스(방향)
      let ny = 와이 + 델타와이(방향)
      if levels.타일에서(레벨, nx, ny) == 0 { 엑스 = nx; 와이 = ny }
    }
    case "a" => {
      if 엑스 == 레벨.열쇠엑스 && 와이 == 레벨.열쇠와이 && !열쇠 {
        열쇠 = true; 메시지 = 1
      } else if 엑스 == 레벨.가방엑스 && 와이 == 레벨.가방와이 && 열쇠 && !가방 {
        가방 = true; 메시지 = 2
      } else if 엑스 == 레벨.출구엑스 && 와이 == 레벨.출구와이 && 가방 {
        // 탈출: 다음 칸이 있으면 그 칸 처음으로(통과), 마지막이면 전체 클리어(끝=1). / escape: advance to next car, or full win if last.
        let 다음 = levels.다음레벨(직전.레벨아이디)
        if 다음 != "" { return state.레벨시작상태(직전.시드, 다음, 7) }
        return state.상태재구성(직전, 엑스, 와이, 방향, 열쇠, 가방, false, 1, 직전.경비단계들, 3, 직전.턴 + 1)
      }
    }
    case "b" => {
      if 인접좌석(레벨, 엑스, 와이) { 숨음 = true; 메시지 = 6 }
    }
    case "init" => {}
    case "resume" => {}
    case "goL01" => {}
    case "goL02" => {}
    case "goL03" => {}
    case "goL04" => {}
    case "goL05" => {}
    case "unknown" => {}
  }

  // 2) 흔들림: 안 버텼으면 이번 턴 흔들림 방향(±x)으로 한 칸 밀린다(바닥/출구만, 벽·좌석은 막힘). 예측 가능 = 도구.
  let 흔들 = levels.흔들림방향(레벨, 직전.턴)
  if 흔들 != 0 && !숨음 {
    let jx = 엑스 + 흔들
    if levels.타일에서(레벨, jx, 와이) == 0 || (jx == 레벨.출구엑스 && 와이 == 레벨.출구와이) { 엑스 = jx }
  }

  // 3) 경비 한 칸 전진(phase+1, 순환).
  let mut 새단계: Array<int> = []
  let mut gi = 0
  for ph in 직전.경비단계들 {
    let len = 레벨.경비들[gi].순찰.length
    새단계.push((ph + 1) % len)
    gi = gi + 1
  }

  // 4) 발각: 경비가 플레이어 칸에 오면(충돌, 버텨도 잡힘) 또는 시야에 들어오면(버티면 면역).
  let mut 잡힘 = false
  let mut j = 0
  for ph in 새단계 {
    let pt = 경비점(레벨, j, ph)
    if pt.엑스 == 엑스 && pt.와이 == 와이 { 잡힘 = true }
    if !숨음 && 시야적중(레벨, pt.엑스, pt.와이, pt.방향, 엑스, 와이) { 잡힘 = true }
    j = j + 1
  }
  if 잡힘 { return state.상태재구성(직전, 엑스, 와이, 방향, 열쇠, 가방, 숨음, 2, 새단계, 4, 직전.턴 + 1) }

  // 5) 놓침(제한턴 도달).
  let 새턴 = 직전.턴 + 1
  if 새턴 >= 직전.제한턴 { return state.상태재구성(직전, 엑스, 와이, 방향, 열쇠, 가방, 숨음, 3, 새단계, 5, 새턴) }

  state.상태재구성(직전, 엑스, 와이, 방향, 열쇠, 가방, 숨음, 0, 새단계, 메시지, 새턴)
}

// ── 드로우리스트 ── (JS 셸이 렌더. GRID 한 번 + 다음턴 위험칸 + 행위자 + 아이템 + HUD.)
function 위험줄들(레벨: levels.레벨, 단계들: Array<int>) -> string {
  // 다음 경비 위치(phase+1)의 시야 칸 = 플레이어가 한 턴 쓰면 감시될 칸. 계획용. / next-phase LOS = cells watched if you spend a turn.
  let mut s = ""
  let mut j = 0
  for ph in 단계들 {
    let len = 레벨.경비들[j].순찰.length
    let pt = 경비점(레벨, j, (ph + 1) % len)
    let mut x = pt.엑스
    let mut y = pt.와이
    let mut k = 0
    while k < 레벨.시야거리 {
      x = x + 델타엑스(pt.방향)
      y = y + 델타와이(pt.방향)
      let t = levels.타일에서(레벨, x, y)
      if t == 1 || t == 2 { k = 레벨.시야거리 } else { s = "{s}\nDANGER|{x}|{y}"; k = k + 1 }
    }
    j = j + 1
  }
  s
}

export function 드로우리스트(s: state.상태) -> string {
  let 레벨 = levels.레벨얻기(s.레벨아이디)
  let mut 격자문 = ""
  for t in 레벨.격자 { 격자문 = "{격자문}{t}" }
  let mut 경비줄 = ""
  let mut j = 0
  for ph in s.경비단계들 {
    let pt = 경비점(레벨, j, ph)
    경비줄 = "{경비줄}\nGUARD|{pt.엑스}|{pt.와이}|{pt.방향}"
    j = j + 1
  }
  let 열쇠줄 = if !s.열쇠있음 { "\nITEM|key|{레벨.열쇠엑스}|{레벨.열쇠와이}" } else { "" }
  let 가방줄 = if !s.가방있음 { "\nITEM|case|{레벨.가방엑스}|{레벨.가방와이}" } else { "" }
  let 출구줄 = "\nITEM|exit|{레벨.출구엑스}|{레벨.출구와이}"
  let 위험 = if s.끝 == 0 { 위험줄들(레벨, s.경비단계들) } else { "" }
  // 다음 흔들림(현재 턴부터): 방향(±1·0) + 몇 턴 후 + 지금 버틸 수 있나. 플레이어가 흔들림을 읽고 계획하게. / next jolt (dir + turns-until) + can-brace-here.
  let mut 흔들방 = 0
  let mut 흔들후 = 0
  let mut k = 0
  while k < 레벨.흔들주기 + 1 {
    let d = levels.흔들림방향(레벨, s.턴 + k)
    if d != 0 { 흔들방 = d; 흔들후 = k; k = 레벨.흔들주기 + 1 } else { k = k + 1 }
  }
  let 버틸수 = if 인접좌석(레벨, s.엑스, s.와이) { 1 } else { 0 }
  let 흔들줄 = "\nJOLT|{흔들방}|{흔들후}|{버틸수}"
  // 다음 턴 경비 위치(phase+1) — 셸의 수(手) 미리보기가 충돌까지 판정하도록. / next-turn guard positions for the shell's move preview (collision).
  let mut 다음경비줄 = ""
  if s.끝 == 0 {
    let mut k2 = 0
    for ph in s.경비단계들 {
      let len = 레벨.경비들[k2].순찰.length
      let pt = 경비점(레벨, k2, (ph + 1) % len)
      다음경비줄 = "{다음경비줄}\nNEXTPOS|{pt.엑스}|{pt.와이}"
      k2 = k2 + 1
    }
  }
  "DT1\nGRID|{레벨.너비}|{레벨.높이}|{격자문}\nPLAYER|{s.엑스}|{s.와이}|{s.방향}|{불2(s.가방있음)}|{불2(s.숨음)}{경비줄}{열쇠줄}{가방줄}{출구줄}{위험}{흔들줄}{다음경비줄}\nHUD|{s.턴}|{s.제한턴}|{불2(s.열쇠있음)}|{불2(s.가방있음)}|{s.끝}|{s.메시지}|{레벨.번호}"
}
function 불2(b: bool) -> int { if b { 1 } else { 0 } }

function 처리(입력: string) -> string {
  let 이벤트값 = 이벤트파싱(줄값(입력, "EVENT:"))
  let 셸 = 줄값(입력, "SHELL:")
  let 셸시드 = 셸값(셸, "seed")
  let 시드 = if 셸시드 != "" { 셸시드 } else { 기본시드 }
  let 새상태 = match 이벤트값 {
    case "init" => state.기본상태(시드)
    case "resume" => state.역직렬화(줄값(입력, "STATE:"), 시드)
    case "goL01" => state.레벨시작상태(시드, "L01", 0)   // 칸 선택: 해당 칸 처음부터(기록 도전). / car select: start a chosen car.
    case "goL02" => state.레벨시작상태(시드, "L02", 0)
    case "goL03" => state.레벨시작상태(시드, "L03", 0)
    case "goL04" => state.레벨시작상태(시드, "L04", 0)
    case "goL05" => state.레벨시작상태(시드, "L05", 0)
    case "up" => 적용(이벤트값, state.역직렬화(줄값(입력, "STATE:"), 시드))
    case "down" => 적용(이벤트값, state.역직렬화(줄값(입력, "STATE:"), 시드))
    case "left" => 적용(이벤트값, state.역직렬화(줄값(입력, "STATE:"), 시드))
    case "right" => 적용(이벤트값, state.역직렬화(줄값(입력, "STATE:"), 시드))
    case "a" => 적용(이벤트값, state.역직렬화(줄값(입력, "STATE:"), 시드))
    case "b" => 적용(이벤트값, state.역직렬화(줄값(입력, "STATE:"), 시드))
    case "unknown" => 적용(이벤트값, state.역직렬화(줄값(입력, "STATE:"), 시드))
  }
  "BT1\nSTATE:{state.직렬화(새상태)}\nDRAW:{드로우리스트(새상태)}"
}

print(처리(input()))



// ===== core/state.tpz =====
// 비밀열차 STATE — 결정론 턴제 잠입. 동적 사실만 담는다(레벨 정적 데이터는 data.levels). 경비는 위치 대신 순찰 단계(phase)만 저장 → 위치·방향은 patrol[phase]에서 유도(결정론·압축).
// Dynamic facts only; guards store just their patrol PHASE (pos/facing derived from the level's patrol[phase]). Pure function of (seed + inputs).

import data.levels

export type 상태 = {
  시드: string, 레벨아이디: string,
  턴: int, 제한턴: int,
  엑스: int, 와이: int, 방향: int,           // 방향 0상1우2하3좌 / facing
  열쇠있음: bool, 가방있음: bool,
  숨음: bool,                                 // 이번 턴 좌석에 버텼는가 — 흔들림에 안 밀리고 시야에도 안 잡힌다(연출+판정). / braced this turn: not slid by a jolt, not seen by LOS.
  끝: int,                                    // 0 진행 / 1 탈출 / 2 발각 / 3 놓침 (play/escape/caught/leftBehind)
  경비단계들: Array<int>,                     // 경비별 순찰 phase / per-guard patrol phase
  메시지: int                                 // 한글 셸 텍스트 코드 / message code for the shell
}

// 특정 레벨의 시작 상태(경비 시작 phase는 레벨별로 어긋나게). 레벨 진행 시 재사용. / fresh start state for any level (per-level guard start phases).
export function 레벨시작상태(시드: string, 레벨아이디: string, 메시지: int) -> 상태 {
  let lv = levels.레벨얻기(레벨아이디)
  {
    시드: 시드, 레벨아이디: 레벨아이디, 턴: 0, 제한턴: lv.제한턴,
    엑스: lv.시작엑스, 와이: lv.시작와이, 방향: 2,
    열쇠있음: false, 가방있음: false, 숨음: false, 끝: 0, 경비단계들: lv.시작단계들, 메시지: 메시지
  }
}

export function 기본상태(시드: string) -> 상태 { 레벨시작상태(시드, "L01", 0) }

// 바뀐 필드만 갈아끼운 새 상태(시드·레벨·제한턴 보존). 턴 로직이 한 번에 재구성한다. / rebuild with the changed turn fields; static fields preserved.
export function 상태재구성(직전: 상태, 엑스: int, 와이: int, 방향: int, 열쇠있음: bool, 가방있음: bool, 숨음: bool, 끝: int, 경비단계들: Array<int>, 메시지: int, 턴: int) -> 상태 {
  직전{
    턴: 턴, 엑스: 엑스, 와이: 와이, 방향: 방향,
    열쇠있음: 열쇠있음, 가방있음: 가방있음, 숨음: 숨음, 끝: 끝,
    경비단계들: 경비단계들, 메시지: 메시지
  }
}

function 불(b: bool) -> int { if b { 1 } else { 0 } }
function 단계직렬(단계들: Array<int>) -> string {
  let mut s = ""
  for p in 단계들 { s = if s == "" { "{p}" } else { "{s};{p}" } }
  if s == "" { "-" } else { s }
}

export function 직렬화(s: 상태) -> string {
  "BT1|{s.시드}|{s.레벨아이디}|{s.턴}|{s.제한턴}|{s.엑스}|{s.와이}|{s.방향}|{불(s.열쇠있음)}|{불(s.가방있음)}|{s.끝}|{단계직렬(s.경비단계들)}|{s.메시지}|{불(s.숨음)}"
}

function 단계파싱(부분: string) -> Array<int> {
  let mut r: Array<int> = []
  if 부분 == "-" || 부분 == "" { return r }
  for t in 부분.split(";") { r.push(toInt(t) ?? 0) }
  r
}

export function 역직렬화(문자열: string, 대체시드: string) -> 상태 {
  let 부분 = 문자열.split("|")
  if 부분.length < 13 { return 기본상태(대체시드) }
  let lv = levels.레벨얻기(부분[2])
  {
    시드: 부분[1], 레벨아이디: 부분[2],
    턴: toInt(부분[3]) ?? 0, 제한턴: toInt(부분[4]) ?? lv.제한턴,
    엑스: toInt(부분[5]) ?? lv.시작엑스, 와이: toInt(부분[6]) ?? lv.시작와이, 방향: toInt(부분[7]) ?? 2,
    열쇠있음: (toInt(부분[8]) ?? 0) == 1, 가방있음: (toInt(부분[9]) ?? 0) == 1,
    끝: toInt(부분[10]) ?? 0, 경비단계들: 단계파싱(부분[11]), 메시지: toInt(부분[12]) ?? 0,
    숨음: 부분.length >= 14 && (toInt(부분[13]) ?? 0) == 1
  }
}



// ===== data/levels.tpz =====
// 비밀열차 레벨 데이터(저작). 타일 '#'=벽, 'S'=좌석(둘 다 이동·시야 차단; 좌석에 붙으면 버티기 가능), 그 외=바닥. 흐름: 시작 → 열쇠 → 가방(열쇠 필요) → 출구.
// 칸막이가 길을 가르고, 경비가 통로를 왕복하며 길목을 비춘다. 제한턴이 빡빡해 흔들림을 타야 시간 안에 탈출(흔들림 필수). 레벨이 오를수록 건너야 할 통로가 늘어난다.
// Secret-train level data. Barriers force the path through guarded chokepoints; the tight turn limit makes riding the jolt necessary. Higher levels = more corridors to cross.

export type 순찰점 = { 엑스: int, 와이: int, 방향: int }   // 방향 0=상 1=우 2=하 3=좌 / facing
export type 경비 = { 순찰: Array<순찰점> }
export type 레벨 = {
  번호: int,
  너비: int, 높이: int, 격자: Array<int>,
  시작엑스: int, 시작와이: int, 열쇠엑스: int, 열쇠와이: int,
  가방엑스: int, 가방와이: int, 출구엑스: int, 출구와이: int,
  제한턴: int, 시야거리: int,
  흔들주기: int, 동쪽턴: int, 서쪽턴: int,   // 결정론 흔들림: 턴%주기 가 동쪽턴이면 동(+x), 서쪽턴이면 서(-x). 예측 가능 = 도구. / deterministic jolt schedule.
  경비들: Array<경비>, 시작단계들: Array<int>  // 경비별 시작 순찰 phase(레벨마다 어긋나게 배치) / per-guard initial patrol phase.
}

// 레벨 1: 칸막이 한 줄(3·9열 틈) + 통로(4행)를 경비 1명이 왕복. 최단 30턴 + 슬랙 1. / one barrier, one guarded corridor.
let 행들L01 = [
  "#############",
  "#...........#",
  "#.S.S.S.S.S.#",
  "###.#####.###",
  "#...........#",
  "#.S.S.S.S.S.#",
  "#...........#",
  "#...........#",
  "#############"
]
// 레벨 2: 칸막이 두 줄 + 통로 두 줄(3행·6행)을 경비 2명이 왕복 — 건너야 할 길목이 둘. 최단 33턴 + 슬랙 1. / two barriers, two guarded corridors.
let 행들L02 = [
  "#############",
  "#...........#",
  "###.#####.###",
  "#...........#",
  "#.S.S.S.S.S.#",
  "###.#####.###",
  "#...........#",
  "#...........#",
  "#############"
]
// 레벨 3: 칸막이 세 줄 + 통로 세 줄(3·5·7행)을 경비 3명이 왕복 — 위(열쇠)에서 아래(가방·출구)까지 셋을 모두 건너야 한다. 최단 37턴 + 슬랙 3. / three guarded corridors.
let 행들L03 = [
  "#############",
  "#...........#",
  "###.#####.###",
  "#...........#",
  "###.#####.###",
  "#...........#",
  "###.#####.###",
  "#...........#",
  "#############"
]
// 레벨 4 (참신): 트인 객실 + 교차 시야 — 가로 경비 1 + 세로(열) 경비 1이 오르내리며 비춘다. 움직이는 빛줄기를 꿰뚫어라. / open room, crossing sightlines.
let 행들L04 = [
  "#############",
  "#...........#",
  "#.S.S.S.S.S.#",
  "#...........#",
  "#.S.S.S.S.S.#",
  "#...........#",
  "#.S.S.S.S.S.#",
  "#...........#",
  "#############"
]

function 행파싱(행: string) -> Array<int> {
  let mut r: Array<int> = []
  for c in 행.scalars() {
    if c == "#" { r.push(1) } else if c == "S" { r.push(2) } else { r.push(0) }
  }
  r
}
function 격자만들기(행들: Array<string>) -> Array<int> {
  let mut 격자: Array<int> = []
  for 행 in 행들 { for t in 행파싱(행) { 격자.push(t) } }
  격자
}

// 한 행(통로)을 좌→우→좌로 왕복. 진행 방향을 보며 통로를 비춰 길목을 번갈아 감시한다. / a guard pacing a corridor row, facing its travel direction.
function 통로경비(와이: int) -> 경비 {
  let mut 순: Array<순찰점> = []
  let mut x = 1
  while x <= 11 { 순.push({ 엑스: x, 와이: 와이, 방향: 1 }); x = x + 1 }   // →
  x = 10
  while x >= 2 { 순.push({ 엑스: x, 와이: 와이, 방향: 3 }); x = x - 1 }     // ←
  { 순찰: 순 }
}

// 통로의 일부 구간만 왕복(짧은 순찰) — 안전 횡단 창이 넓어져 1번 칸을 가르치는 관대한 레벨로. / a SHORT row patrol; wider safe windows for a gentle teaching level.
function 통로경비범위(와이: int, 시작: int, 끝: int) -> 경비 {
  let mut 순: Array<순찰점> = []
  let mut x = 시작
  while x <= 끝 { 순.push({ 엑스: x, 와이: 와이, 방향: 1 }); x = x + 1 }   // →
  x = 끝 - 1
  while x >= 시작 + 1 { 순.push({ 엑스: x, 와이: 와이, 방향: 3 }); x = x - 1 }   // ←
  { 순찰: 순 }
}

// 한 열(세로)을 위→아래→위로 왕복. 시야가 세로로 행을 가로질러 비춘다(가로 경비와 교차). / a guard pacing a column up<->down, vertical LOS.
function 세로경비(엑스: int) -> 경비 {
  let mut 순: Array<순찰점> = []
  let mut y = 1
  while y <= 7 { 순.push({ 엑스: 엑스, 와이: y, 방향: 2 }); y = y + 1 }   // ↓
  y = 6
  while y >= 2 { 순.push({ 엑스: 엑스, 와이: y, 방향: 0 }); y = y - 1 }     // ↑
  { 순찰: 순 }
}

export function 레벨얻기(레벨아이디: string) -> 레벨 {
  if 레벨아이디 == "L05" {
    {
      번호: 5,
      너비: 13, 높이: 9, 격자: 격자만들기(행들L04),   // 같은 트인 객실 / same open room
      시작엑스: 1, 시작와이: 1, 열쇠엑스: 11, 열쇠와이: 1,
      가방엑스: 11, 가방와이: 7, 출구엑스: 1, 출구와이: 7,
      제한턴: 32, 시야거리: 6,   // 세로 빛줄기 둘 사이를 꿰뚫기. 최단 30 + 슬랙 2. / thread two vertical beams.
      흔들주기: 5, 동쪽턴: 2, 서쪽턴: 4,
      경비들: [세로경비(5), 세로경비(9)], 시작단계들: [0, 0]
    }
  } else if 레벨아이디 == "L04" {
    {
      번호: 4,
      너비: 13, 높이: 9, 격자: 격자만들기(행들L04),
      시작엑스: 1, 시작와이: 1, 열쇠엑스: 11, 열쇠와이: 1,
      가방엑스: 11, 가방와이: 7, 출구엑스: 1, 출구와이: 7,
      제한턴: 34, 시야거리: 6,   // 트인 객실 + 교차 시야. ~68% 발각(5번보다 낮게 = 단조 증가 커브). 흔들림 필수. / open room; ~68% caught (below car5 = monotonic).
      흔들주기: 5, 동쪽턴: 2, 서쪽턴: 4,
      경비들: [통로경비(3), 세로경비(9)], 시작단계들: [0, 3]
    }
  } else if 레벨아이디 == "L03" {
    {
      번호: 3,
      너비: 13, 높이: 9, 격자: 격자만들기(행들L03),
      시작엑스: 1, 시작와이: 1, 열쇠엑스: 11, 열쇠와이: 1,
      가방엑스: 11, 가방와이: 7, 출구엑스: 1, 출구와이: 7,
      제한턴: 42, 시야거리: 6,   // ~51% 발각(2번 34% ↗ 4번 68%로 완만히). / ~51% caught, between car2 and car4.
      흔들주기: 5, 동쪽턴: 2, 서쪽턴: 4,
      경비들: [통로경비(3), 통로경비(5), 통로경비(7)], 시작단계들: [0, 0, 7]
    }
  } else if 레벨아이디 == "L02" {
    {
      번호: 2,
      너비: 13, 높이: 9, 격자: 격자만들기(행들L02),
      시작엑스: 1, 시작와이: 1, 열쇠엑스: 11, 열쇠와이: 1,
      가방엑스: 11, 가방와이: 7, 출구엑스: 1, 출구와이: 7,
      제한턴: 38, 시야거리: 6,   // 완만한 램프: 한 경비는 짧은 순찰 → 이탈 발각 ~34%(1번 10% ↗ 뒷칸으로 상승). / smooth ramp ~34% caught.
      흔들주기: 7, 동쪽턴: 3, 서쪽턴: 6,
      경비들: [통로경비(3), 통로경비범위(6, 3, 9)], 시작단계들: [6, 6]
    }
  } else {
    {
      번호: 1,
      너비: 13, 높이: 9, 격자: 격자만들기(행들L01),
      시작엑스: 1, 시작와이: 1, 열쇠엑스: 11, 열쇠와이: 1,
      가방엑스: 11, 가방와이: 6, 출구엑스: 1, 출구와이: 6,
      제한턴: 36, 시야거리: 6,   // 가르치는 첫 칸: 넉넉한 슬랙 + 짧은 순찰 → 관대(이탈 10%만 발각). 흔들림은 소개만; 뒷 칸에서 필수화. / gentle teaching level.
      흔들주기: 7, 동쪽턴: 3, 서쪽턴: 6,   // 드문 흔들림(소개용) / infrequent jolt, just to introduce it
      경비들: [통로경비범위(4, 5, 8)], 시작단계들: [0]
    }
  }
}

// 다음 레벨 아이디("" = 마지막, 전체 클리어). / next level id ("" = last, full win).
export function 다음레벨(레벨아이디: string) -> string {
  if 레벨아이디 == "L01" { "L02" } else if 레벨아이디 == "L02" { "L03" } else if 레벨아이디 == "L03" { "L04" } else if 레벨아이디 == "L04" { "L05" } else { "" }
}

// 이 턴의 흔들림 방향: 1 동(+x) · -1 서(-x) · 0 없음. 결정론(턴의 순수 함수). / jolt direction this turn.
export function 흔들림방향(레벨: 레벨, 턴: int) -> int {
  if 턴 <= 0 { return 0 }
  if 턴 % 레벨.흔들주기 == 레벨.동쪽턴 { return 1 }
  if 턴 % 레벨.흔들주기 == 레벨.서쪽턴 { return -1 }
  0
}

// 타일 조회(범위 밖 = 벽). / tile lookup (out of bounds = wall).
export function 타일에서(레벨: 레벨, 엑스: int, 와이: int) -> int {
  if 엑스 < 0 || 와이 < 0 || 엑스 >= 레벨.너비 || 와이 >= 레벨.높이 { return 1 }
  레벨.격자[와이 * 레벨.너비 + 엑스]
}