방향키 또는 D-패드로 원시 연못을 걷습니다. 키큰풀에 들어서면 야생 미생물과 조우할 수 있습니다. 같은 시드·같은 경로면 늘 같은 곳에서 같은 조우가 재현됩니다. START로 파티를, SELECT로 계통도감을 엽니다.
↑ ↓ ← → 이동 · A(Z)로 결정 · B(X)로 취소·도망 · START(Enter) 파티 · SELECT(Shift) 계통도감
// ===== main.tpz =====
// slow.monster 진입점(P8: 초점 동료 + P5 분기 진화 + P6 계통 도감 + 스토리 큐). 입력 패킷을 파싱해 코어 리듀서를
// 돌리고 출력 패킷을 프린트한다. 배틀은 파티[초점] 한 마리가 맡고, 계통수는 숲으로 자라며, 스토리 카드는 STATE 비트셋으로 결정론화된다.
// slow.monster entry (P8: focused creature + P5 branch evolution + P6 lineage dex + story queue). Parse the input packet,
// run the core reducer, print the output packet. Topaz is a pure deterministic stepper: one packet -> next STATE + one DRAW.
//
// 하우스룰(app): app 계층은 패킷 파싱 + 코어 호출 + 프린트만. 게임 로직은 코어(순수)에 있다. Host는 엔트리 input/print만.
// House rule (app): packet-parse + core-call + print only; game logic lives in pure core. Host = entry input/print only.
//
// 패킷 계약:
// 입력: SM1 / STATE:<직전 STATE 또는 빈칸> / EVENT:<init|up|down|left|right|a|b|start|select> / SHELL:<k=v,...>
// 출력: SM1 / STATE:<새 STATE> / DRAW:<드로우리스트>
//
// ── 파티와 배틀 / party & battle ──────────────────────────────────────────────────────────────────
// STATE의 파티는 전투원 배열이고 배틀의 "활성 전투원"은 초점 슬롯(파티[초점])이다. 배틀·드리프트·진화 결과는 같은 슬롯에 새 배열로 끼운다.
// The party is a combatant array; the active battler is the focused slot (party[focus]). Battle/drift/evolution results are spliced back into that slot.
//
// Key identifiers: 처리=handle, 줄값=lineValue, 셸값=shellValue, 이동=move, 적용=applyEvent, 기본시드=defaultSeed,
// 필드적용=applyField, 배틀적용=applyBattle, 파티적용=applyParty, 활성전투원=activeBattler(초점), 파티슬롯교체=replaceSlot(focus).
import core.state
import core.drawlist { 드로우리스트 }
import core.creature
import core.battle
import core.genome { 야생계통시드, 진화준비, 진화임계, 드리프트가두기 }
import core.clash // 삼투 압력전(독자 전투): 압력전시작·비트진행. / the osmosis-clash battle engine.
import core.evolution // §17: 진화 후보·결과·노드 타입을 한정 참조(evolution.후보 등)하므로 모듈째 import. / whole-module import for qualified P5 evolution types.
import data.maps { 맵타일들, 타일에서, 워프에서, 타일_물, 타일_나무 }
import data.species { 종찾기 }
import data.entities { 활성엔티티에서, 활성엔티티있음, 엔티티_야생, 엔티티_NPC }
import core.auto { 자동결정 }
import core.story { 파생비트, 비트있음, 비트켜기, 비트끄기, 최하위비트, 비트수 }
import core.util { 범위가두기 }
let 기본시드 = "SM-SEED-001"
type 이벤트 = "init" | "resume" | "up" | "down" | "left" | "right" | "a" | "b" | "start" | "select" | "auto" | "unknown"
function 이벤트파싱(토큰: string) -> 이벤트 {
match 토큰.trim() {
case "init" => "init"
case "resume" => "resume"
case "up" => "up"
case "down" => "down"
case "left" => "left"
case "right" => "right"
case "a" => "a"
case "b" => "b"
case "start" => "start"
case "select" => "select"
case "auto" => "auto"
case _ => "unknown"
}
}
function 진행입력(이벤트값: 이벤트) -> bool {
match 이벤트값 {
case "a" => true
case "b" => true
case "init" => false
case "resume" => false
case "up" => false
case "down" => false
case "left" => false
case "right" => false
case "start" => false
case "select" => false
case "auto" => false
case "unknown" => false
}
}
// 문자열에서 접두사 뒤의 꼬리를 돌려준다(s.slice는 스칼라 인덱스·클램프). / Return the tail after a prefix (scalar-indexed, clamped string slice).
function 접두사뒤(전체: string, 접두사: string) -> string {
전체.slice(접두사.scalars().length, 전체.scalars().length)
}
// "PREFIX:" 로 시작하는 줄에서 접두사 뒤의 값을 뽑는다(없으면 빈 문자열). 패킷 한 줄 = 한 키.
// Extract the value after "PREFIX:" from the matching line (empty if absent). One packet line = one key.
function 줄값(원본: string, 접두사: string) -> string {
let mut 값 = ""
for 줄 in 원본.split("\n") {
let 다듬 = 줄.trim()
if 다듬.startsWith(접두사) {
값 = 접두사뒤(다듬, 접두사)
}
}
값.trim()
}
// SHELL 줄의 "k=v,k=v" 에서 한 키의 값을 찾는다(없으면 빈칸). reduced_motion 등 셸 힌트용.
// Find one key's value in the SHELL line's "k=v,k=v" (empty if absent). For shell hints like reduced_motion.
function 셸값(셸: string, 키: string) -> string {
let mut 값 = ""
for 쌍 in 셸.split(",") {
let 부분 = 쌍.split("=")
if 부분.length == 2 && 부분[0].trim() == 키 { 값 = 부분[1].trim() }
}
값
}
// 파티의 활성 전투원 = 초점(키우는 동료). 이 한 마리만 배틀·드리프트·진화한다. 초점은 [0,파티수-1]로 가둬져 있어 안전. / The active battler = the FOCUSED creature.
function 활성전투원(에스: state.상태) -> creature.전투원 {
let f = 범위가두기(에스.초점, 0, 에스.파티.length - 1)
에스.파티[f]
}
// 파티 슬롯 <인덱스>를 새 전투원으로 교체한 새 파티 배열을 만든다(나머지 슬롯 보존, 새 배열로 복사 = 순수). 초점이 가리키는 슬롯에 배틀/진화 결과를 끼운다.
// Build a fresh party array with slot <index> replaced by the new combatant (others preserved; copied = pure). Used to write back the focused creature.
function 파티슬롯교체(파티: Array<creature.전투원>, 인덱스: int, 새것: creature.전투원) -> Array<creature.전투원> {
let mut 결과: Array<creature.전투원> = []
let mut i = 0
while i < 파티.length {
if i == 인덱스 { 결과.push(새것) } else { 결과.push(파티[i]) }
i = i + 1
}
결과
}
// 방향만큼 한 칸 옮긴 목적 좌표를 만들고, 통행 가능하면 옮긴다(아니면 제자리, 방향만 바뀜). 통행 불가 = 물(3)·나무(4)·엔티티가 선 칸.
// 랜덤 조우는 없다 — 걷는 동안 화면이 끊기지 않는다. 조우/대화는 오직 마주 선 엔티티에게 A를 눌러야 일어난다(상호작용 함수 참고).
// Build the destination one step in `방향`; move if walkable, else stay (facing changes). Blocked = water(3)/tree(4)/an entity tile.
// NO random encounters — walking is never interrupted. Battles/talk happen ONLY by facing an entity and pressing A (see 상호작용).
function 이동(에스: state.상태, 방향: state.방향) -> state.상태 {
let mut 새엑스 = 에스.플레이어.엑스
let mut 새와이 = 에스.플레이어.와이
if 방향 == 1 { 새와이 = 새와이 - 1 } // 위 / up
else if 방향 == 0 { 새와이 = 새와이 + 1 } // 아래 / down
else if 방향 == 2 { 새엑스 = 새엑스 - 1 } // 왼쪽 / left
else if 방향 == 3 { 새엑스 = 새엑스 + 1 } // 오른쪽 / right
let 타일들 = 맵타일들(에스.맵아이디)
let 타일 = 타일에서(타일들, 새엑스, 새와이)
// 엔티티가 선 칸은 막아 서서 마주 보게 한다(다가가 A로 상호작용). 물·나무도 통행 불가.
// An entity tile blocks so the player stands facing it (walk up, press A). Water/tree also block.
let 막힘 = 타일 == 타일_물 || 타일 == 타일_나무 || 활성엔티티있음(에스.맵아이디, 새엑스, 새와이, 에스.처치된것들)
let 목적엑스 = if 막힘 { 에스.플레이어.엑스 } else { 새엑스 }
let 목적와이 = if 막힘 { 에스.플레이어.와이 } else { 새와이 }
// 옮긴 칸에 워프가 있으면 목적 맵·목적 좌표로 전이한다(생물군계 이동). 없으면 같은 맵 그대로. / a warp on the destination moves to the target map+pos.
let 워프 = 워프에서(에스.맵아이디, 목적엑스, 목적와이)
let 워프됨 = 워프.목적맵 != ""
let 최종맵 = if 워프됨 { 워프.목적맵 } else { 에스.맵아이디 }
let 최종엑스 = if 워프됨 { 워프.목적엑스 } else { 목적엑스 }
let 최종와이 = if 워프됨 { 워프.목적와이 } else { 목적와이 }
let 새스텝 = 에스.스텝 + 1
에스{
스텝: 새스텝,
플레이어: 에스.플레이어{ 엑스: 최종엑스, 와이: 최종와이, 방향: 방향 },
맵아이디: 최종맵,
장면: "field",
야생: state.빈야생()
}
}
// 마주 보는 칸의 좌표(현재 방향 기준 한 칸 앞). 0아래·1위·2왼·3오른. / The faced tile (one step ahead by facing): 0 down /1 up /2 left /3 right.
function 마주엑스(플레이어: state.플레이어) -> int {
if 플레이어.방향 == 2 { 플레이어.엑스 - 1 } else if 플레이어.방향 == 3 { 플레이어.엑스 + 1 } else { 플레이어.엑스 }
}
function 마주와이(플레이어: state.플레이어) -> int {
if 플레이어.방향 == 1 { 플레이어.와이 - 1 } else if 플레이어.방향 == 0 { 플레이어.와이 + 1 } else { 플레이어.와이 }
}
// A 상호작용(필드): 마주 본 칸의 엔티티를 본다. 야생이면 그 종·레벨로 배틀을 연다(결정론: 카운터는 스텝·엔티티 위치에서 파생).
// NPC면 대화는 마주 섰을 때 드로우리스트가 그려주므로 상태 변화 없음(증분1: 처치 영속화 없이 재대전 가능). 빈 칸이면 그대로.
// A-interaction (field): inspect the faced tile. A wild entity opens a battle with its species/level (deterministic counter from
// step + faced position). An NPC needs no state change (the field draw shows its line while faced). Empty tile = no-op.
function 상호작용(에스: state.상태) -> state.상태 {
let 칸엑스 = 마주엑스(에스.플레이어)
let 칸와이 = 마주와이(에스.플레이어)
let 대상 = 활성엔티티에서(에스.맵아이디, 칸엑스, 칸와이, 에스.처치된것들)
if 대상.종류 == 엔티티_야생 {
let 적종 = 종찾기(대상.종아이디)
let 야생 = {
종아이디: 적종.아이디,
이름: 적종.이름,
레벨: 대상.레벨,
체력: 적종.기본체력,
스프라이트: 적종.스프라이트,
속성: 적종.속성
}
에스{
장면: "battle",
야생: 야생,
배틀: clash.압력전시작(대상.종아이디, 대상.레벨) // 삼투 압력전 시작(메뉴 없는 결정론 전투) / start the osmosis clash (menu-less deterministic battle)
}
} else if 대상.종류 == 엔티티_NPC {
// NPC 와 마주 서서 A → 휴식. 파티 전체가 회복한다(레벨·진화 진척은 그대로). 긴 세션을 체력 소모에서 살린다.
// A at an NPC = rest: the whole party heals (level/evolution progress preserved), so long sessions survive HP attrition.
state.파티회복(에스)
} else {
에스
}
}
// 승리·포획 직후 호출: 마주 본 칸의 야생 엔티티를 처치셋에 넣어 맵에서 사라지게 한다. 위치 기반 — 배틀 중 플레이어는 안 움직였으므로 여전히 그 야생을 마주 본다. NPC·빈칸이면 그대로.
// Called right after a win/catch: add the faced WILD to the cleared set so it vanishes. Position-based — the player never moved during battle, so the faced tile is still that wild. No-op for NPC/empty.
function 처치적용(에스: state.상태) -> state.상태 {
let 대상 = 활성엔티티에서(에스.맵아이디, 마주엑스(에스.플레이어), 마주와이(에스.플레이어), 에스.처치된것들)
if 대상.종류 == 엔티티_야생 {
에스{ 처치된것들: state.처치추가(에스.처치된것들, 대상.아이디) }
} else {
에스
}
}
// 필드 장면의 이벤트 처리: 방향키 = 이동. a = 마주 본 엔티티와 상호작용(야생→배틀). start = 파티뷰. select = 계통도감. b = 제자리.
// Field-scene event handling: arrows = move; a = interact with the faced entity (wild -> battle); start = party; select = dex; b = no-op.
// 네비게이션 결정(P6): START 는 이미 파티뷰를 연다 → 도감은 SELECT 로 연다(목업 셸의 SELECT 버튼/Shift 키와 연결, 둘 다 같은 step 이벤트).
// Navigation (P6): START already opens the party view, so the dex opens on SELECT (the mockup's SELECT button + Shift key, same event).
function 필드적용(이벤트값: 이벤트, 직전: state.상태) -> state.상태 {
match 이벤트값 {
case "up" => 이동(직전, 1)
case "down" => 이동(직전, 0)
case "left" => 이동(직전, 2)
case "right" => 이동(직전, 3)
case "a" => 상호작용(직전)
case "start" => 파티열기(직전)
case "select" => 도감열기(직전)
case "init" => 직전
case "resume" => 직전
case "b" => 직전
case "auto" => 직전
case "unknown" => 직전
}
}
// 파티뷰를 연다(장면 party, 커서 0). 위치/파티/보관함 보존. / Open the party view (scene=party, cursor 0); preserve everything else.
function 파티열기(직전: state.상태) -> state.상태 {
직전{ 장면: "party", 파티커서: 0 }
}
// 파티뷰를 닫고 필드로 복귀한다(커서 0으로). / Close the party view and return to field (cursor reset).
function 파티닫기(직전: state.상태) -> state.상태 {
직전{ 장면: "field", 파티커서: 0 }
}
// 파티뷰 커서만 바꾼 새 상태. / A new state with only the party-view cursor changed.
function 파티커서설정(직전: state.상태, 커서: int) -> state.상태 {
직전{ 장면: "party", 파티커서: 커서 }
}
// 파티뷰 이벤트 처리: 위/아래 = 커서(파티 길이로 순환). b/start = 닫기. / Party-view input: up/down cursor (wraps), b/start closes.
function 파티적용(이벤트값: 이벤트, 직전: state.상태) -> state.상태 {
match 이벤트값 {
case "b" => { return 파티닫기(직전) }
case "start" => { return 파티닫기(직전) }
case "init" => ()
case "resume" => ()
case "up" => ()
case "down" => ()
case "left" => ()
case "right" => ()
case "a" => ()
case "select" => ()
case "auto" => ()
case "unknown" => ()
}
let 길이 = 직전.파티.length
if 길이 <= 0 { return 직전 }
match 이벤트값 {
case "up" => {
let mut 새 = 직전.파티커서 - 1
if 새 < 0 { 새 = 길이 - 1 }
파티커서설정(직전, 새)
}
case "down" => {
let mut 새 = 직전.파티커서 + 1
if 새 >= 길이 { 새 = 0 }
파티커서설정(직전, 새)
}
// A: 커서가 가리키는 동료를 초점으로 삼는다(이제 이 한 마리가 배틀·드리프트·진화한다). 파티뷰는 배틀 밖이라 안전. / A sets the focused creature.
case "a" => 직전{ 초점: 범위가두기(직전.파티커서, 0, 길이 - 1) }
case "init" => 직전
case "resume" => 직전
case "left" => 직전
case "right" => 직전
case "b" => 직전
case "start" => 직전
case "select" => 직전
case "auto" => 직전
case "unknown" => 직전
}
}
// ── P6 Lineage Dex(계통도감) 네비게이션 ──────────────────────────────────────────────────────────────
// 계통도감을 연다(장면 "dex"). 도감 커서는 보존한다(닫았다 다시 열면 같은 노드를 가리킨다). 다른 필드는 그대로.
// Open the Lineage Dex (scene "dex"); preserve the dex cursor (re-opening points at the same node). Everything else unchanged.
function 도감열기(직전: state.상태) -> state.상태 {
직전{ 장면: "dex" }
}
// 계통도감을 닫고 필드로 복귀한다(도감 커서는 보존). / Close the dex and return to field (the dex cursor is preserved).
function 도감닫기(직전: state.상태) -> state.상태 {
직전{ 장면: "field" }
}
// 도감 커서만 바꾼 새 상태(다른 필드 보존). 커서는 호출자가 노드 수로 가둔다(도감적용에서 순환). / A new state with only the dex cursor changed.
function 도감커서설정(직전: state.상태, 커서: int) -> state.상태 {
직전{ 장면: "dex", 도감커서: 커서 }
}
// 도감 장면 이벤트 처리: b/select = 닫기. 방향키 = 커서 이동(계통 노드 리스트 길이로 순환). a = 제자리(상세는 항상 보임).
// 커서는 발견 순서(노드 리스트 인덱스)를 따라 순환한다 — 레이아웃은 이 인덱스로 선택 노드를 비춘다(결정론). 빈 트리(노드 0)는 커서 0 고정.
// Dex-scene input: b/select closes; arrows move the cursor (wraps over the lineage-node list length); a is a no-op (the detail is always shown).
// The cursor cycles by discovery order (node-list index); the layout highlights the selected node by this index. An empty tree pins cursor at 0.
function 도감적용(이벤트값: 이벤트, 직전: state.상태) -> state.상태 {
match 이벤트값 {
case "b" => { return 도감닫기(직전) }
case "select" => { return 도감닫기(직전) }
case "init" => ()
case "resume" => ()
case "up" => ()
case "down" => ()
case "left" => ()
case "right" => ()
case "a" => ()
case "start" => ()
case "auto" => ()
case "unknown" => ()
}
let 길이 = 직전.계통노드들.length
if 길이 <= 0 { return 직전 } // 빈 트리: 커서 이동 없음(상세도 비어 있음) / empty tree: no cursor movement
// ←/↑ = 이전 노드(순환), →/↓ = 다음 노드(순환). 발견 순서로 도는 단순·결정론 커서. / prev/next over discovery order (wraps).
match 이벤트값 {
case "left" => {
let mut 새 = 직전.도감커서 - 1
if 새 < 0 { 새 = 길이 - 1 }
도감커서설정(직전, 새)
}
case "up" => {
let mut 새 = 직전.도감커서 - 1
if 새 < 0 { 새 = 길이 - 1 }
도감커서설정(직전, 새)
}
case "right" => {
let mut 새 = 직전.도감커서 + 1
if 새 >= 길이 { 새 = 0 }
도감커서설정(직전, 새)
}
case "down" => {
let mut 새 = 직전.도감커서 + 1
if 새 >= 길이 { 새 = 0 }
도감커서설정(직전, 새)
}
case "init" => 직전
case "resume" => 직전
case "a" => 직전
case "b" => 직전
case "start" => 직전
case "select" => 직전
case "auto" => 직전
case "unknown" => 직전
}
}
// 직전 상태에 새 배틀 서브상태·슬롯0 교체 파티를 끼워 새 상태 레코드를 만든다(나머지 필드 보존, 야생/볼/포획카 추가 갱신 가능).
// Rebuild state with a new battle + a slot-0-replaced party (other fields preserved; wild/balls/captureCounter can be overridden too).
function 배틀갱신(직전: state.상태, 새배틀: battle.배틀상태, 새활성: creature.전투원) -> state.상태 {
직전{ 장면: "battle", 파티: 파티슬롯교체(직전.파티, 직전.초점,새활성), 배틀: 새배틀 }
}
// 배틀을 끝내고 필드로 복귀한다(야생·배틀 비움, 스텝/위치/시드 보존). 패배면 활성 전투원을 완전 회복(P2: 블랙아웃 = 풀힐).
// End the battle and return to field (clear wild + battle). On lose, full-heal the active battler (P2 blackout = full heal).
function 배틀종료(직전: state.상태, 새활성: creature.전투원, 패배: bool) -> state.상태 {
// 패배 시 풀힐: 계통시드·드리프트·진화 형태(세대·진화카운터·형태이름·스탯부스트·게놈)는 그대로 보존한다(블랙아웃이 게놈/진화 진척을 지우면 안 된다).
// On loss, full-heal but PRESERVE lineage seed + drift + the evolved form (generation/evoCounter/formName/statBoost/genome).
let 회복활성 = if 패배 {
creature.전투원복원(새활성.종아이디, 새활성.레벨, 새활성.경험, 새활성.최대체력, 새활성.계통시드, 새활성.드리프트, 새활성.세대, 새활성.진화카운터, 새활성.형태이름, 새활성.스탯부스트, 새활성.게놈)
} else {
새활성
}
직전{ 장면: "field", 야생: state.빈야생(), 파티: 파티슬롯교체(직전.파티, 직전.초점,회복활성), 배틀: battle.빈배틀() }
}
// 야생을 잡아 파티(또는 보관함)에 넣고 배틀을 끝낸다(필드 복귀). 잡은 야생은 종·레벨에서 전투원으로 유도(체력 가득).
// Catch the wild, add to party (or box), end the battle (back to field). The caught creature derives from species + level (full HP).
function 포획성공처리(직전: state.상태, 새볼: int, 새포획카: int) -> state.상태 {
// 잡은 야생의 계통시드 = (월드시드 + 포획 스텝·위치·종). 같은 시드+같은 경로로 같은 스텝/칸에서 잡으면 같은 게놈(재현·트위스트의 핵심).
// The caught wild's lineage seed = (world seed + catch step/position/species). Catching at the same step/cell with the same seed -> the same genome.
let 계통시드 = 야생계통시드(직전.월드시드, 직전.스텝, 직전.플레이어.엑스, 직전.플레이어.와이, 직전.야생.종아이디)
let 잡은것 = creature.종에서전투원(직전.야생.종아이디, 직전.야생.레벨, 계통시드)
let 추가 = state.파티추가(직전.파티, 직전.보관함, 잡은것)
직전{
장면: "field",
야생: state.빈야생(),
스포어볼: 새볼, 포획카운터: 새포획카,
파티: 추가.파티,
보관함: 추가.보관함,
계통노드들: state.노드추가(직전.계통노드들, state.시작노드(잡은것, 직전.스텝)),
배틀: battle.빈배틀()
}
}
// ── P5 분기 진화 LOCK 장면 ───────────────────────────────────────────────────────────────────────
// 진화 장면 진입: 승리·레벨업·준비로 분기 진화가 발사됐을 때(다음단계 "evolve"), 진화 서브상태에 부모 스냅샷을 박고 장면을 "evolve" 로 바꾼다.
// 부모 스냅샷(부모게놈·부모계통시드·부모진화카운터·부모세대·부모형태·부모유아이디)으로 후보들을 재유도한다(저장 대신 재현 → 항상 결정론).
// Enter the evolution scene: when a branch-evolution fired (다음단계 "evolve"), snapshot the parent into the evolution sub-state and switch
// scene to "evolve". Candidates are RE-DERIVED from the snapshot (reproduced, not stored). 활성 = the post-win/leveled (not-yet-locked) battler.
function 진화장면진입(직전: state.상태, 활성: creature.전투원) -> state.상태 {
let 부모형태 = if 활성.형태이름 != "" { 활성.형태이름 } else { 종찾기(활성.종아이디).이름 }
// 부모유아이디 = 부모 계통시드의 유아이디(난수("mutation",0,0,계통시드)). 잠근 뒤 자식 노드의 부모유아이디로 박혀 계통수 가지를 잇는다.
// parent uid = the parent lineage-seed uid; stamped as the child node's 부모유아이디 to wire the lineage-tree edge.
let 부모유아이디 = evolution.계통유아이디(활성.계통시드)
let 단계값: state.진화단계 = "choose"
let 새진화 = {
단계: 단계값,
부모형태: 부모형태,
부모게놈: 활성.게놈,
부모계통시드: 활성.계통시드,
부모진화카운터: 활성.진화카운터,
부모세대: 활성.세대,
부모유아이디: 부모유아이디,
선택커서: 0,
잠긴분기: 0,
결과형태: "",
결과게놈: 활성.게놈
}
직전{ 장면: "evolve", 야생: state.빈야생(), 진화: 새진화, 배틀: battle.빈배틀() }
}
// 부모 스냅샷으로 후보 분기들을 재유도한다(메뉴·고정 양쪽이 같은 함수를 써 같은 후보를 본다 → 재현). 항상 (부모게놈+계통시드+진화카운터)의 순수 함수.
// Re-derive the candidate branches from the parent snapshot (both the menu and the lock use this -> identical candidates -> reproducible).
function 진화후보들(직전: state.상태) -> Array<evolution.후보> {
let 진 = 직전.진화
let 엔티티 = evolution.계통유아이디(진.부모계통시드)
evolution.후보들유도(진.부모게놈, 엔티티, 진.부모계통시드, 진.부모진화카운터)
}
// 패배로 취급되는 배틀 결과 처리: 계통이 패배에서 배우고, 준비되면 그 자리에서 진화한다. / Handle battle outcomes treated as loss/adaptation.
function 배틀패배처리(직전: state.상태, 활성: creature.전투원) -> state.상태 {
let 학습 = creature.드리프트설정(활성, 드리프트가두기(활성.드리프트 + 진화임계))
if 진화준비(학습.드리프트) {
진화장면진입(배틀갱신(직전, battle.빈배틀(), 학습), 학습)
} else {
배틀종료(직전, 학습, true)
}
}
// 압력전 해소(result) 단계: 진행 입력(a/b)에서만 결과를 소비한다. / Consume a resolved clash only on advance input.
function 배틀결과단계적용(이벤트값: 이벤트, 직전: state.상태, 활성: creature.전투원, 데모: bool) -> state.상태 {
if 진행입력(이벤트값) {
let 야생 = 직전.야생
match 직전.배틀.결과 {
case "caught" => {
// 포자 정착 성공 → 스포어볼 하나 소모 + 파티 합류 + 맵에서 제거. / spore-settle: consume a ball, join the party, clear the wild.
처치적용(포획성공처리(직전, 직전.스포어볼 - 1, 직전.포획카운터 + 1))
}
case "win" => {
// 승리 → 경험/드리프트. 레벨업 + 진화 준비면 분기 장면, 아니면 야생 제거. 진화장면진입은 초점 슬롯에 키운 전투원이 들어간 상태를 받는다.
// Win -> EXP/drift. If it leveled and is evolution-ready, the branch scene; else clear the wild. 진화장면진입 needs the leveled creature already in the focus slot.
let 성장 = battle.경험적용(활성, creature.획득경험(야생.레벨), 데모)
let 키운 = 성장.전투원
if 성장.오른레벨 > 0 && 진화준비(키운.드리프트) {
처치적용(진화장면진입(배틀갱신(직전, battle.빈배틀(), 키운), 키운))
} else {
처치적용(배틀종료(직전, 키운, false))
}
}
case "lose" => {
// 패배 → 계통이 패배에서 배운다(적응). 드리프트를 얻고 풀힐하며, 준비되면 그 자리에서 진화해 더 강해져 재도전한다. 야생은 안 비운다(다시 붙음).
// 이로써 약한 세이브(옛 버전 진척)도 죽음 루프 대신 "고전→적응→돌파"로 회복한다. 신규 게임은 이기므로 이 경로를 안 탄다.
// Loss -> the lineage LEARNS: gain drift + full heal, and if ready, evolve in place to return stronger. The wild is NOT cleared. A weak/stale save recovers via adaptation instead of a death-loop; fresh games (which win) never hit this path.
배틀패배처리(직전, 활성)
}
case "fled" => 배틀패배처리(직전, 활성)
case "" => 배틀패배처리(직전, 활성)
}
} else {
직전
}
}
// 비해소 단계는 기존처럼 모두 같은 압력전 비트 진행으로 접힌다. 레거시 menu/move/message STATE도 이 경로를 유지한다.
// Non-result phases all keep the existing clash-beat path; legacy menu/move/message STATEs stay on that path.
function 배틀비트단계적용(이벤트값: 이벤트, 직전: state.상태, 활성: creature.전투원) -> state.상태 {
if 진행입력(이벤트값) {
// 포획은 숲을 몇 그루 만들 때까지만(파티 3마리 미만). 그 뒤엔 쓰러뜨려 초점 동료를 키운다(경험·드리프트→진화→보스). / catch only until the forest has a few roots (party<3), then DEFEAT to cultivate the focus.
let 포획원함 = 직전.파티.length < 3
let 비 = 직전.배틀
let 야생 = 직전.야생
let 결과 = clash.비트진행(직전.월드시드, 비, 활성, 야생.종아이디, 야생.레벨, 직전.스포어볼, 포획원함)
배틀갱신(직전, 결과.배틀, 결과.플레이어)
} else {
직전
}
}
// 배틀 서브상태기. result는 결과 소비, 나머지는 압력전 비트 진행. / Battle sub-state machine: result consumes the outcome; others advance the clash.
function 배틀적용(이벤트값: 이벤트, 직전: state.상태, 데모: bool) -> state.상태 {
let 활성 = 활성전투원(직전)
match 직전.배틀.단계 {
case "result" => 배틀결과단계적용(이벤트값, 직전, 활성, 데모)
case "clash" => 배틀비트단계적용(이벤트값, 직전, 활성)
case "menu" => 배틀비트단계적용(이벤트값, 직전, 활성)
case "move" => 배틀비트단계적용(이벤트값, 직전, 활성)
case "message" => 배틀비트단계적용(이벤트값, 직전, 활성)
}
}
// ── 진화 장면: 선택커서만 바꾼 새 상태(다른 필드 보존). / A new state with only the evolution cursor changed.
function 진화커서설정(직전: state.상태, 커서: int) -> state.상태 {
let 진 = 직전.진화
let 새진화 = 진{ 선택커서: 커서 }
직전{ 장면: "evolve", 진화: 새진화 }
}
// 진화 LOCK: 고른 분기를 영구 고정한다. evolution.분기진화 로 잠긴 전투원을 만들고(파티 슬롯 0 교체), 계통 노드를 append 한다.
// 진화 서브상태를 "morph" 단계로 바꿔(결과형태·결과게놈·잠긴분기 박음) JS 모핑 컷신을 1프레임 트리거한다. 같은 시드+같은 분기 → 같은 잠긴 형태(byte 동일·재현).
// Evolution LOCK: lock the chosen branch permanently. Build the locked combatant via evolution.분기진화 (replace party slot 0), append the
// lineage node, and flip the evolution sub-state to "morph" (stamping result form/genome/branch) to trigger the JS morph cutscene for one frame.
function 진화고정(직전: state.상태) -> state.상태 {
let 진 = 직전.진화
let 활성 = 활성전투원(직전) // 초점 슬롯 = 진화 직전(레벨업까지 끝난) 전투원 / focused slot = the post-level-up creature to evolve
let 후보들 = 진화후보들(직전)
// 선택커서를 후보 범위로 가둔다(범위 밖 → 0). 분기진화 내부에서도 한 번 더 가두므로 이중 방어. / clamp the cursor into the candidate range.
let 분기 = 범위가두기(진.선택커서, 0, 후보들.length - 1)
// 분기진화: (부모 전투원 + 분기 + 부모 세대/진화카운터/스텝/부모유아이디) → 잠긴 새 전투원 + 노드 재료. 결정론·재현.
let 결과 = evolution.분기진화(활성, 분기, 진.부모세대, 진.부모진화카운터, 직전.스텝, 진.부모유아이디)
// 계통 노드 append(같은 유아이디 중복은 노드추가가 막음 → 멱등). / append the lineage node (idempotent on uid).
let 노드 = evolution.노드빌드(결과, 진.부모유아이디, 직전.스텝)
let 새노드들 = state.노드추가(직전.계통노드들, 노드)
// 진화 서브상태 → morph(잠긴 분기·결과형태·결과게놈 박음). 다음 이벤트가 이걸 보고 모핑 컷신을 끝낸 뒤 필드로 보낸다.
let 단계값: state.진화단계 = "morph"
let 새진화 = 진{
단계: 단계값,
선택커서: 분기,
잠긴분기: 결과.분기,
결과형태: 결과.형태이름,
결과게놈: 결과.전투원.게놈
}
직전{
장면: "evolve",
야생: state.빈야생(),
파티: 파티슬롯교체(직전.파티, 직전.초점,결과.전투원),
계통노드들: 새노드들,
진화: 새진화,
배틀: battle.빈배틀()
}
}
// morph 단계에서 다음 이벤트가 오면 진화를 끝내고 필드로 복귀한다(진화 서브상태 비움). 모핑 컷신은 JS가 이 1프레임에 본 EVOLVE 디렉티브로 재생한다.
// On the next event in the morph phase, finish the evolution and return to field (clear the evolution sub-state). The JS plays the morph from the EVOLVE directive seen on that one frame.
function 진화종료(직전: state.상태) -> state.상태 {
직전{ 장면: "field", 야생: state.빈야생(), 진화: state.빈진화(), 배틀: battle.빈배틀() }
}
// 진화 장면 입력 처리. choose: ←→(또는 ↑↓)로 후보 커서, a 로 LOCK. morph: 아무 키나 진화 종료(필드 복귀). 결정론: 커서·선택이 입력 시퀀스라 재현된다.
// Evolution-scene input. choose: arrows move the candidate cursor, a LOCKS. morph: any key ends the evolution (back to field).
// Reproducible: the cursor + the lock choice are part of the input sequence, so the same seed + same inputs lock the SAME branch.
function 진화적용(이벤트값: 이벤트, 직전: state.상태) -> state.상태 {
let 진 = 직전.진화
match 진.단계 {
case "morph" => {
// morph: 잠금 직후 1프레임(JS가 EVOLVE 디렉티브로 모핑). 아무 키나 진화를 끝내고 필드로. / morph frame: any key finishes -> field.
진화종료(직전)
}
case "choose" => {
let 후보들 = 진화후보들(직전)
let 수 = 후보들.length
match 이벤트값 {
case "left" => {
let mut 새 = 진.선택커서 - 1
if 새 < 0 { 새 = 수 - 1 }
진화커서설정(직전, 새)
}
case "up" => {
let mut 새 = 진.선택커서 - 1
if 새 < 0 { 새 = 수 - 1 }
진화커서설정(직전, 새)
}
case "right" => {
let mut 새 = 진.선택커서 + 1
if 새 >= 수 { 새 = 0 }
진화커서설정(직전, 새)
}
case "down" => {
let mut 새 = 진.선택커서 + 1
if 새 >= 수 { 새 = 0 }
진화커서설정(직전, 새)
}
// LOCK: 고른 분기를 영구 고정한다(되돌릴 수 없음 — 진화는 발사되면 반드시 한 분기로 고정된다). / lock the chosen branch (irreversible).
case "a" => 진화고정(직전)
case "init" => 직전
case "resume" => 직전
case "b" => 직전
case "start" => 직전
case "select" => 직전
case "auto" => 직전
case "unknown" => 직전
}
}
case "none" => {
// 단계가 "none"(있을 수 없음)이면 안전하게 필드로. / a "none" phase (shouldn't happen) safely returns to field.
진화종료(직전)
}
}
}
// 이벤트를 직전 상태에 적용한다. 장면으로 분기(field/battle/party/evolve/dex). / Apply an event to the prev state, branching on the scene.
// 데모 플래그는 드리프트가 쌓이는 곳(배틀적용 → 턴진행)까지만 전달한다 — 다른 장면은 드리프트를 건드리지 않아 플래그를 쓰지 않는다.
// The demo flag is only forwarded to where drift accrues (배틀적용 -> 턴진행); other scenes don't touch drift, so they ignore it.
// 스토리 큐 갱신: 도달한 마일스톤 중 아직 안 본 것을 본것·대기에 켜고(한 번만), 필드라면 story 장면으로 전환한다. 매 전이 뒤 호출(처리). 본것은 enqueue 시점에 켠다(세이브/재유도 중복 방지).
// Story-queue update: turn on newly-reached unseen milestones in seen+queue (fire-once), and if on the field switch to the story scene. Called after every transition. 'seen' is set at ENQUEUE.
function 스토리갱신(에스: state.상태) -> state.상태 {
let 파생 = 파생비트(에스)
let mut 새본것 = 에스.스토리.본것
let mut 새대기 = 에스.스토리.대기
let mut k = 0
while k < 비트수 {
if 비트있음(파생, k) && !비트있음(에스.스토리.본것, k) {
새본것 = 비트켜기(새본것, k)
새대기 = 비트켜기(새대기, k)
}
k = k + 1
}
// 비차단 장면(field)이고 대기가 있으면 story 카드로. 배틀/진화/도감 중이면 큐만 쌓고 기다린다(STATE에 남아 필드로 돌아올 때 뜸).
// From the field with a nonempty queue -> the story card; while in a blocking scene, just queue (it stays in STATE and shows back at the field).
let 새장면: state.장면 = match 에스.장면 {
case "field" if 새대기 != 0 => "story"
case "field" => "field"
case "battle" => "battle"
case "party" => "party"
case "dex" => "dex"
case "story" => "story"
case "evolve" => "evolve"
}
에스{ 장면: 새장면, 스토리: { 본것: state.비트가두기(새본것), 대기: state.비트가두기(새대기) } }
}
// 스토리 카드 진행(A): 지금 카드(대기 최하위 비트)를 큐에서 빼고, 큐가 남으면 다음 카드, 비면 필드로. / Advance the story card: drain the lowest queue bit; next card if any, else back to field.
function 스토리진행(직전: state.상태) -> state.상태 {
let 현재 = 최하위비트(직전.스토리.대기)
let 새대기 = if 현재 >= 0 { 비트끄기(직전.스토리.대기, 현재) } else { 직전.스토리.대기 }
let 새장면: state.장면 = if 새대기 != 0 { "story" } else { "field" }
직전{ 장면: 새장면, 스토리: { 본것: 직전.스토리.본것, 대기: state.비트가두기(새대기) } }
}
function 적용(이벤트값: 이벤트, 직전: state.상태, 데모: bool, 의도: int) -> state.상태 {
// 자동 진행: 코어가 다음 입력 하나를 정하고(자동결정, 재배 의도 반영) 같은 엔진으로 적용한다(두 번째 엔진이 아님). ""(무행동)이면 그대로 — 수동 분기 선택 등 플레이어 몫이라 멈춘다.
// Auto-play: the core decides the next input (honouring the cultivation intent) and applies it via the SAME engine. "" (no-op) leaves state unchanged (a manual-branch pause).
if 이벤트값 == "auto" {
let 자동입력 = 이벤트파싱(자동결정(직전, 의도))
if 자동입력 == "unknown" { return 직전 }
return 적용(자동입력, 직전, 데모, 의도)
}
match 직전.장면 {
case "story" => 스토리진행(직전) // 스토리 카드: 아무 진행 입력(A 등)으로 다음/닫기 / a story card advances on any input
case "battle" => 배틀적용(이벤트값, 직전, 데모)
case "party" => 파티적용(이벤트값, 직전)
case "evolve" => 진화적용(이벤트값, 직전)
case "dex" => 도감적용(이벤트값, 직전)
case "field" => 필드적용(이벤트값, 직전)
}
}
function 역직렬화또는기본(문자열: string, 시드: string) -> state.상태 {
match state.역직렬화(문자열, 시드) {
case Ok(상태값) => 상태값
case Err(오류) => {
match 오류 {
case "empty" => state.기본상태(시드)
case "short" => state.기본상태(시드)
}
}
}
}
function 처리(입력: string) -> string {
let 이벤트값 = 이벤트파싱(줄값(입력, "EVENT:"))
let 셸 = 줄값(입력, "SHELL:")
// SHELL 의 seed 가 있으면 그것을, 없으면 기본 시드. init 때만 쓴다(이후엔 STATE 안의 월드시드가 권위).
// Use SHELL's seed if present, else the default. Only used on init (afterward STATE's world_seed is authoritative).
let 셸시드 = 셸값(셸, "seed")
let 시드 = if 셸시드 != "" { 셸시드 } else { 기본시드 }
// 데모 모드(opt-in): SHELL 의 demo=1 이면 드리프트가 빨리 쌓여 ~2승에 진화한다(재미 게이트를 빨리 평가). 없으면(기본) 느린 브랜드 그대로.
// 플래그는 입력의 일부라 데모-온/오프 각각 결정론적이다(시드+입력+플래그 같으면 재현). 기본(데모 없음) 동작은 바이트 동일.
// Demo mode (opt-in): SHELL's demo=1 makes drift accrue fast (~2 wins to evolve) to evaluate the fun gate quickly; absent = the slow brand.
// The flag is part of the input, so each mode is deterministic; the default (no demo) is byte-identical.
let 데모 = 셸값(셸, "demo") == "1"
// 재배 의도(슬라이스2): 자동 진행이 진화 갈림길에서 어떻게 행동할지. 0 수동(멈춰 플레이어가 고름·기본) / 1 첫 가지 / 2 무작위. SHELL 플래그라 입력의 일부 → 같은 의도면 결정론적.
// Cultivation intent: how auto-play acts at a fork. 0 manual(pause, default) / 1 leftmost / 2 random. A SHELL flag (part of the input) -> deterministic per intent.
let 의도문 = 셸값(셸, "intent")
let 의도 = if 의도문 == "1" { 1 } else if 의도문 == "2" { 2 } else { 0 }
// 모든 경로를 스토리갱신으로 감싼다 — init/resume/액션 뒤 새 마일스톤을 큐에 넣고 필드면 카드로. init 때 첫 조우 비트가 켜져 오프닝 카드로 시작한다.
// Wrap every path in 스토리갱신 so a new milestone enqueues after init/resume/action; on init the first-encounter bit opens with a card.
let 상태문자열 = 줄값(입력, "STATE:")
let 새상태 = match 이벤트값 {
case "init" => 스토리갱신(state.기본상태(시드))
case "resume" => {
// 저장 복원: 들어온 STATE 를 그대로 역직렬화해 렌더한다(액션 없음). 어떤 장면이든 안전하게 이어 보게 한다(새로고침·시드공유 부팅).
// Resume a save: deserialize the incoming STATE and render it with NO action, so any scene resumes safely (reload / seed-share boot).
스토리갱신(역직렬화또는기본(상태문자열, 시드))
}
case "up" => 스토리갱신(적용(이벤트값, 역직렬화또는기본(상태문자열, 시드), 데모, 의도))
case "down" => 스토리갱신(적용(이벤트값, 역직렬화또는기본(상태문자열, 시드), 데모, 의도))
case "left" => 스토리갱신(적용(이벤트값, 역직렬화또는기본(상태문자열, 시드), 데모, 의도))
case "right" => 스토리갱신(적용(이벤트값, 역직렬화또는기본(상태문자열, 시드), 데모, 의도))
case "a" => 스토리갱신(적용(이벤트값, 역직렬화또는기본(상태문자열, 시드), 데모, 의도))
case "b" => 스토리갱신(적용(이벤트값, 역직렬화또는기본(상태문자열, 시드), 데모, 의도))
case "start" => 스토리갱신(적용(이벤트값, 역직렬화또는기본(상태문자열, 시드), 데모, 의도))
case "select" => 스토리갱신(적용(이벤트값, 역직렬화또는기본(상태문자열, 시드), 데모, 의도))
case "auto" => 스토리갱신(적용(이벤트값, 역직렬화또는기본(상태문자열, 시드), 데모, 의도))
case "unknown" => 스토리갱신(적용(이벤트값, 역직렬화또는기본(상태문자열, 시드), 데모, 의도))
}
let 직렬 = state.직렬화(새상태)
let 그림 = 드로우리스트(새상태)
"SM1\nSTATE:{직렬}\nDRAW:{그림}"
}
print(처리(input()))
// ===== core/state.tpz =====
// 게임 상태 레코드 + STATE 문자열 직렬화/역직렬화. 코어는 순수하다: now_millis·파일IO·미시드 난수 없음.
// 게임 전체 = (월드시드 + 입력 시퀀스)의 순수 함수 → 같은 입력은 항상 같은 상태(결정론·리플레이·시드 공유의 토대).
// Game-state record + STATE-string serialize/deserialize. Pure core: no now_millis, no file IO, no unseeded RNG.
// The whole game is a pure function of (world_seed + input sequence) — same input always yields the same state.
//
// 하우스룰(core): export는 타입/함수/불변 최상위만. 최상위 가변 상태·print·input·host 금지(지역 let mut는 허용).
// House rule (core): export only types/functions/immutable bindings; no module-level mutable state, no print/input/host.
//
// ── STATE 문자열 형식 v4(한 줄, 파이프 구분, 라운드트립 보장) ─────────────────────────────────────────
// P4는 v4: 각 전투원이 4필드(종·레벨·경험·체력)에서 6필드(+계통시드·드리프트)로 넓어진다. 게놈은 계통시드에서 유도하므로 저장 안 함.
// 계통시드는 파이프/콜론을 피해 만든 문자열("seed#starter", "seed#wild@step:x:y:species") — 안전을 위해 직렬화 때 콜론(:)으로 파이프 충돌을 피한다.
// [0]v4 [1]seed [2]step [3]px [4]py [5]dir [6]map [7]scene("field"|"battle"|"party")
// [8]wildSp [9]wildLv [10]wildHp [11]wildSpr [12]wildType [13]wildName
// [14]sporeBalls [15]captureCounter
// [16]partyCount, 이어서 partyCount*6 필드(종·레벨·경험·체력·계통시드·드리프트) ← 가변 길이 파티 / variable party (6 fields each)
// [N]boxCount, 이어서 boxCount*6 필드(종·레벨·경험·체력·계통시드·드리프트) ← 가변 길이 보관함 / variable box (6 fields each)
// [M]partyCursor ← 파티뷰 커서 / party-view cursor
// 마지막 9토큰: btPhase btMenuCur btMoveCur btCounter btEnemyHp btMsgCur btNext btResult btMessage(메시지 마지막)
// scene: "field"/"battle"/"party". 야생·배틀 슬롯은 battle 일 때만 의미가 있다. 파티는 항상 1마리 이상(init에서 지급).
// scene: "field"/"battle"/"party"; the wild + battle slots matter only in battle; the party always holds >=1 (granted at init).
// 메시지는 ";;" 로 줄을 잇는다(파이프/줄바꿈 회피). split("|") 의 마지막 토큰이 메시지 전체다. / messages join lines with ";;".
//
// ── 하위호환 / backward compatibility ──────────────────────────────────────────────────────────────
// v3(전투원당 4필드)·v2(27필드 단일 파티 [14..17])·v1(14필드)·v0(7필드)도 받는다. v3→v4 마이그레이션: 레거시 전투원에는
// 계통시드가 없으므로 결정론적으로 하나 부여한다 — 파티 i번째는 "seed#legacy:party:i", 보관함 j번째는 "seed#legacy:box:j" — 그리고 드리프트 0.
// 같은 v3 STATE 는 항상 같은 레거시 게놈으로 마이그레이트된다(결정론). 단일 파티(v2)·빈 파티(v0/v1)도 같은 규칙으로 시드를 받는다.
// Accepts v3 (4 fields/combatant), v2 (single party [14..17]), v1, v0. v3->v4 migration: legacy combatants have no lineage seed,
// so we assign one DETERMINISTICALLY — party member i gets "seed#legacy:party:i", box member j "seed#legacy:box:j" — drift 0.
// The same v3 STATE always migrates to the same legacy genome (deterministic). Every restored level/HP/drift stays clamped.
//
// Key identifiers: 상태=State, 플레이어=player, 월드시드=worldSeed, 스텝=stepIndex, 장면=scene, 야생=wild,
// 파티=party(전투원 Array), 보관함=box(전투원 Array), 스포어볼=sporeBalls, 포획카운터=captureCounter,
// 파티커서=partyCursor, 배틀=battle, 기본상태=defaultState, 직렬화=serialize, 역직렬화=deserialize,
// 파티최대=PARTY_MAX, 파티가득=partyFull, 파티추가=addToParty.
// §17: 크로스모듈 타입은 네임스페이스 한정 참조다. 그래서 encounter·creature·battle 을 모듈째 import 한다.
// §17: cross-module types are namespace-qualified, so import encounter/creature/battle whole.
import core.encounter
import core.creature
import core.battle
import core.evolution // §17: 계통노드 타입을 한정 참조(evolution.계통노드)하므로 모듈째 import. / whole-module import for the qualified 계통노드 type.
import core.genome // §17: 게놈 타입을 한정 참조(genome.게놈)하려면 모듈째 import. 값 함수는 한정 이름. / whole-module import (qualified 게놈 type + value fns).
import core.rng { 난수 }
import core.util { 범위가두기, 복사, 복사추가 }
import data.species { 시작종아이디, 종찾기 }
import data.items { 시작스포어볼 }
// 파티 최대 인원. 차면 잡은 야생은 보관함으로 간다. / Max party size; overflow goes to the box.
export let 파티최대 = 6
// ── 역직렬화 방어선: 파싱한 수치 필드를 안전 범위로 가둔다(조작·손상된 STATE의 거대값이 하류 checked-int 산술을 넘기지 못하게). ──
// 정당한(범위 내) 값은 그대로 통과한다 — 가두기는 범위 밖 조작값에만 작용하므로 정상 플레이 출력은 바이트 동일하다.
// Deserialize defense line: clamp parsed numeric fields to a SAFE range so a crafted/corrupt STATE's huge value cannot overflow
// the downstream checked-int math. Legitimate (in-range) values pass through UNCHANGED — clamping only bites out-of-range crafted input.
//
// 좌표상한 = 9999: 24x24 맵을 한참 넘는다(걸어서 닿을 수 없음). 드로우리스트의 (엑스-camx)*타일크기(=16) ≤ ~160000 ≪ i64max → 곱셈 안전,
// 이동의 새엑스/새와이 ±1 도 안전. / Coordinate cap: far beyond the 24x24 map; (x-camx)*16 ≤ ~160000, and the ±1 move add are both safe.
let 좌표상한 = 9999
// 카운터상한 = 1e9: 스텝/경험/포획카운터/전투카운터/체력에 쓴다. +1·+적레벨*14 같은 하류 덧셈이 절대 오버플로하지 않고(1e9 + 1e9 ≪ i64max),
// 정상 플레이는 이 근처에도 못 온다(스텝은 한 번에 +1, 경험은 한 판 ~1386). / Large-counter cap: step/exp/capture+battle counters/HP; +1 and +enemyLv*14 never overflow, and honest play never approaches it.
let 카운터상한 = 1000000000
// 좌표(엑스/와이)를 [0, 좌표상한] 으로 가둔다. 드로우리스트 곱셈·이동 덧셈을 보호한다. / Clamp a coordinate to [0, 좌표상한]; protects the drawlist multiply + move add.
function 좌표가두기(값: int) -> int {
범위가두기(값, 0, 좌표상한)
}
// 큰 카운터(스텝·경험·포획·전투카운터·체력)를 [0, 카운터상한] 으로 가둔다. +1·+적레벨*14 하류 덧셈을 보호한다. / Clamp a large counter to [0, 카운터상한]; protects the +1 / +enemyLv*14 adds.
function 카운터가두기(값: int) -> int {
범위가두기(값, 0, 카운터상한)
}
// 스토리 비트셋을 안전 범위 [0, 65535] 로 가둔다(체크 정수 오버플로 방지, 손상 세이브 방어). 토파즈엔 비트연산이 없어 산술 비트셋이다. / clamp a story bitset into a safe range.
export function 비트가두기(값: int) -> int {
범위가두기(값, 0, 65535)
}
export type 방향 = 0 | 1 | 2 | 3
function 방향파싱(값: int) -> 방향 {
if 값 == 1 { return 1 }
if 값 == 2 { return 2 }
if 값 == 3 { return 3 }
0
}
export type 플레이어 = { 엑스: int, 와이: int, 방향: 방향 }
// 장면 태그(STATE 토큰 문자열 그대로). 새 값을 추가하면 드로우·이벤트 분기도 함께 exhaustiveness로 갱신한다.
// Scene tags (exact STATE token strings). Adding a value forces the draw/event dispatch to update exhaustively.
export type 장면 = "field" | "battle" | "party" | "dex" | "story" | "evolve"
// STATE에서 읽은 장면 문자열을 닫힌 장면 태그로 좁힌다. 손상 값은 필드로 안전 복귀한다(정상 세이브 값은 byte 그대로).
// Narrow a serialized scene token into the closed scene tag. Corrupt values safely fall back to field; valid saves stay byte-identical.
function 장면파싱(토큰: string) -> 장면 {
match 토큰 {
case "field" => "field"
case "battle" => "battle"
case "party" => "party"
case "dex" => "dex"
case "story" => "story"
case "evolve" => "evolve"
case _ => "field"
}
}
// P5: 진화 장면 서브상태. 분기 진화 발사 시 채워지고, 플레이어가 분기를 고르면(choose) 잠그고(lock) 모핑 컷신을 트리거한다(morph).
// 단계: "none"(진화 아님) | "choose"(후보 메뉴) | "morph"(잠금 직후 1프레임, JS 모핑 컷신 트리거).
// 부모 스냅샷(부모게놈·부모계통시드·부모진화카운터·부모세대·부모형태·부모유아이디)으로 후보들을 재유도한다(저장 대신 재현 — 항상 결정론).
// 선택커서 = 후보 메뉴 커서. 잠긴분기 = 고른 분기(모핑 descriptor용). 결과형태·결과게놈 = 잠긴 형태(morph to-form/to-genome).
// P5 evolution-scene sub-state. Filled when a branch-evolution fires; the player picks a branch (choose), it locks and triggers the
// morph cutscene (morph). Candidates are RE-DERIVED from the parent snapshot (reproduced, not stored — always deterministic).
export type 진화단계 = "none" | "choose" | "morph"
// 진화 단계 토큰을 닫힌 태그로 좁힌다. 손상 값은 "none"으로 두어 진화 중이 아닌 상태가 된다.
// Narrow a serialized evolution-phase token. Corrupt values become "none" (not evolving).
function 진화단계파싱(토큰: string) -> 진화단계 {
match 토큰 {
case "none" => "none"
case "choose" => "choose"
case "morph" => "morph"
case _ => "none"
}
}
export type 진화상태 = {
단계: 진화단계,
부모형태: string,
부모게놈: genome.게놈,
부모계통시드: string,
부모진화카운터: int,
부모세대: int,
부모유아이디: int,
선택커서: int,
잠긴분기: int,
결과형태: string,
결과게놈: genome.게놈
}
// 스토리 진행(슬라이스3): 본 비트(fire-once) + 대기 비트(보여줄 큐). 둘 다 비트셋 정수(비트=마일스톤 enum). 현재 보여줄 비트 = 대기의 최하위 1비트(유도). 결정론·STATE에 저장.
// Story progress: seen bits (fire-once) + queue bits (to show). Both bitsets (bit = milestone enum). Current beat = lowest set bit of the queue (derived). Deterministic, in STATE.
export type 스토리상태 = { 본것: int, 대기: int }
export type 상태 = {
버전: int,
월드시드: string,
스텝: int,
플레이어: 플레이어,
맵아이디: string,
장면: 장면,
야생: encounter.야생크리처,
스포어볼: int,
포획카운터: int,
파티: Array<creature.전투원>,
보관함: Array<creature.전투원>,
파티커서: int,
// P6: Lineage Dex(계통수) 커서. 도감 장면("dex")에서 선택된 계통 노드 인덱스(발견 순서). 닫으면 보존만 한다(다음에 같은 노드 선택).
// P6: the Lineage-Dex cursor — the index (discovery order) of the selected lineage node in the "dex" scene. Preserved across close.
도감커서: int,
// 처치된 맵 엔티티의 아이디 목록(잡거나 이긴 야생). 카운트 접두 리스트로 직렬화(v6). 비트마스크 금지(토파즈 비트연산 없음) — 아이디 키라 추가식 안전.
// IDs of cleared map entities (caught/defeated wilds). Serialized as a count-prefixed list (v6); ID-keyed (not a bitmask) so it is append-safe.
처치된것들: Array<int>,
// 초점(Focus Specimen): 지금 키우는 동료의 파티 인덱스. 이 한 마리만 배틀·드리프트·진화한다. 전환은 파티뷰에서(배틀 밖). 잡은 동료마다 제 계통(뿌리) → 계통수가 "숲"이 된다.
// The focused party index: ONLY this creature battles, drifts, and evolves. Switch it in the party view (outside battle). Each caught creature is its own lineage root -> the dex becomes a FOREST.
초점: int,
// 스토리 진행 비트셋(본것·대기). 마일스톤을 한 번만 띄우고, scene="story" 카드 큐를 결정론적으로 비운다. / story-progress bitsets (seen + queue).
스토리: 스토리상태,
// P5: 발견된 진화 형태의 계통 노드 리스트(진화마다 append). P6의 Lineage Dex(계통수)가 그릴 발견 기록. 결정론·시드 박힘.
// P5: the discovered evolution-form lineage nodes (appended per evolution) — the record P6's Lineage Dex will draw. Deterministic, seed-stamped.
계통노드들: Array<evolution.계통노드>,
진화: 진화상태,
배틀: battle.배틀상태
}
// 빈 진화 서브상태(진화 아님). 게놈은 0 벡터. / Empty evolution sub-state (not evolving); genome is the zero vector.
export function 빈진화() -> 진화상태 {
let 영게놈 = { 색소: 0, 막: 0, 크기: 0, 대사: 0, 촉수: 0 }
{ 단계: "none", 부모형태: "", 부모게놈: 영게놈, 부모계통시드: "", 부모진화카운터: 0,
부모세대: 0, 부모유아이디: 0, 선택커서: 0, 잠긴분기: 0, 결과형태: "", 결과게놈: 영게놈 }
}
// 빈 야생 크리처(필드 장면일 때의 자리채움). 종아이디 0 = 없음. / An empty wild creature (placeholder in the field scene).
export function 빈야생() -> encounter.야생크리처 {
{ 종아이디: 0, 이름: "", 레벨: 0, 체력: 0, 스프라이트: 0, 속성: 0 }
}
// 파티가 가득 찼는지. / Whether the party is full.
export function 파티가득(파티: Array<creature.전투원>) -> bool {
파티.length >= 파티최대
}
// 잡은 야생을 파티 끝에 덧붙인다. 가득 차 있으면 파티는 그대로 두고 보관함에 덧붙인다. 둘은 동시에 늘지 않는다.
// Append a caught creature to the party; if the party is full, leave it and append to the box instead. (One grows, not both.)
// 결과는 (새파티·새보관함). 호출자는 둘 다 새 상태에 끼운다. / Result = (new party, new box). The caller splices both into the new state.
export type 추가결과 = { 파티: Array<creature.전투원>, 보관함: Array<creature.전투원> }
export function 파티추가(파티: Array<creature.전투원>, 보관함: Array<creature.전투원>, 잡은것: creature.전투원) -> 추가결과 {
if 파티가득(파티) {
{ 파티: 파티, 보관함: 복사추가(보관함, 잡은것) }
} else {
{ 파티: 복사추가(파티, 잡은것), 보관함: 보관함 }
}
}
// 한 전투원의 "원형"(세대 0) 계통 노드를 만든다 — 계통수의 뿌리. 형태이름은 진화 전이라 종 이름을 쓴다(IP 안전·미진화 폴백과 일치).
// 유아이디 = 계통시드 해시(결정론), 부모유아이디 0(뿌리), 발견스텝은 인자(init=0). 같은 시드 → 같은 뿌리 노드. / Build a creature's ROOT (gen-0) lineage node.
export function 시작노드(전투원: creature.전투원, 스텝: int) -> evolution.계통노드 {
let 종정보 = 종찾기(전투원.종아이디)
let 이름 = if 전투원.형태이름 != "" { 전투원.형태이름 } else { 종정보.이름 }
{
유아이디: 노드유아이디(전투원.계통시드), // = 계통시드 해시(결정론, evolution.노드빌드와 같은 정의) / lineage-seed hash (matches evolution's uid)
부모유아이디: 0,
형태이름: 이름,
게놈요약: genome.게놈요약(전투원.게놈),
세대: 전투원.세대,
발견스텝: 스텝,
시드지문: 전투원.계통시드
}
}
// 계통노드 유아이디 = 계통시드의 (mutation,0,0,시드) 난수. evolution.노드빌드의 유아이디 정의와 동일(같은 시드 → 같은 유아이디).
// Lineage-node uid = rand("mutation",0,0,lineageSeed). Same definition as evolution.노드빌드 (same seed -> same uid).
function 노드유아이디(시드: string) -> int {
난수("mutation", 0, 0, 시드)
}
// 계통노드 배열을 새 배열에 복사하고 한 노드를 끝에 덧붙인다(토파즈 배열은 참조형이라 항상 새 배열 = 순수). 같은 유아이디 중복은 막는다(멱등성·재현).
// Copy the lineage-node array into a FRESH array and append one node (arrays are reference-like -> always copy = pure). Skips a duplicate uid (idempotent).
export function 노드추가(원본: Array<evolution.계통노드>, 노드: evolution.계통노드) -> Array<evolution.계통노드> {
let mut 이미있음 = false
for 항목 in 원본 {
if 항목.유아이디 == 노드.유아이디 { 이미있음 = true }
}
let mut 결과 = 복사(원본)
if !이미있음 { 결과.push(노드) }
결과
}
// 시드로 시작 상태를 만든다(결정론적·미시드 난수 없음). 플레이어는 연못 길 위 안전한 시작 칸, 장면은 필드.
// 파티: 시조 아메바(시작종)를 레벨 5로 지급한다(1마리 파티). 보관함은 빈 채, 스포어볼은 시작 재고, 포획카운터 0.
// P5: 계통노드 리스트를 시작종의 원형(세대 0) 노드 하나로 씨앗을 뿌린다(계통수의 뿌리). 버전은 v5.
// Build the initial state from a seed (deterministic). Party: grant 시조 아메바 (starter) at level 5. P5: seed the lineage list with the starter's gen-0 root node; version v5.
export function 기본상태(시드: string) -> 상태 {
let 시작전투원 = creature.종에서전투원(시작종아이디(), 5, genome.시작계통시드(시드))
{
버전: 8,
월드시드: 시드,
스텝: 0,
플레이어: { 엑스: 3, 와이: 5, 방향: 0 },
맵아이디: "pond",
장면: "field",
야생: 빈야생(),
스포어볼: 시작스포어볼(),
포획카운터: 0,
처치된것들: [],
초점: 0,
스토리: { 본것: 0, 대기: 0 }, // 스토리 진행: 아직 본 비트 없음(새 게임) / no story beats seen yet
// 시작종은 "시드#starter" 계통시드를 받는다 → 게놈이 월드시드의 순수 함수(재현). 드리프트 0에서 시작.
// The starter takes the "seed#starter" lineage seed -> its genome is a pure function of the world seed. Drift starts 0.
파티: [시작전투원],
보관함: [],
파티커서: 0,
도감커서: 0,
계통노드들: [시작노드(시작전투원, 0)], // 계통수의 뿌리(시작종 원형) / the root of the lineage tree (the starter's origin form)
진화: 빈진화(),
배틀: battle.빈배틀()
}
}
// 아이디를 처치셋에 추가한다(이미 있으면 그대로 — 멱등). 배열은 참조형이라 새 배열로 복사해 돌려준다. / Append an id (idempotent); returns a fresh array.
export function 처치추가(목록: Array<int>, 아이디: int) -> Array<int> {
let mut 있음 = false
for x in 목록 { if x == 아이디 { 있음 = true } }
let mut 새 = 복사(목록)
if !있음 { 새.push(아이디) }
새
}
// 파티 전체를 완전 회복시킨 새 상태(나머지 보존). NPC 와 마주 서서 A(휴식)면 호출된다 — 긴 세션이 체력 소모로 무너지지 않게.
// Return state with the whole party full-healed (rest), everything else preserved. Called on A at an NPC so long sessions do not collapse to HP attrition.
export function 파티회복(에스: 상태) -> 상태 {
let mut 회복파티: Array<creature.전투원> = []
for 원 in 에스.파티 { 회복파티.push(creature.회복(원)) }
에스{ 파티: 회복파티 }
}
// ── 직렬화 헬퍼 / serialization helpers ────────────────────────────────────────────────────────────
// 게놈을 콜론으로 이은 한 토큰으로 직렬화한다("색소:막:크기:대사:촉수"). 파이프가 없어 한 토큰으로 안전(v5). / Serialize a genome as one colon-joined token.
function 게놈직렬(지: genome.게놈) -> string {
"{지.색소}:{지.막}:{지.크기}:{지.대사}:{지.촉수}"
}
// 콜론 토큰을 게놈으로 역직렬화한다. 부족하면 0 으로 채운다(방어). / Parse a colon token back into a genome (missing -> 0).
function 게놈역직렬(토큰: string) -> genome.게놈 {
let 부분 = 토큰.split(":")
let 색소 = if 부분.length >= 1 { toInt(부분[0]) ?? 0 } else { 0 }
let 막 = if 부분.length >= 2 { toInt(부분[1]) ?? 0 } else { 0 }
let 크기 = if 부분.length >= 3 { toInt(부분[2]) ?? 0 } else { 0 }
let 대사 = if 부분.length >= 4 { toInt(부분[3]) ?? 0 } else { 0 }
let 촉수 = if 부분.length >= 5 { toInt(부분[4]) ?? 0 } else { 0 }
{ 색소: 색소, 막: 막, 크기: 크기, 대사: 대사, 촉수: 촉수 }
}
// 전투원 한 마리를 11필드(종·레벨·경험·체력·계통시드·드리프트 + P5: 게놈·세대·진화카운터·형태이름·스탯부스트)로 직렬화한다(v5).
// 게놈은 콜론 토큰(파이프 없음), 형태이름은 파이프 없는 한국어 문자열("" = 미진화). 진화한 전투원도 byte-동일하게 라운드트립한다.
// Serialize one combatant as 11 fields for v5 (P5 adds genome / generation / evoCounter / formName / statBoost). Genome is a
// colon token, formName is a pipe-free Korean string ("" = un-evolved). Evolved combatants round-trip byte-identically.
function 전투원직렬(전투원: creature.전투원) -> string {
"{전투원.종아이디}|{전투원.레벨}|{전투원.경험}|{전투원.현재체력}|{전투원.계통시드}|{전투원.드리프트}|{게놈직렬(전투원.게놈)}|{전투원.세대}|{전투원.진화카운터}|{전투원.형태이름}|{전투원.스탯부스트}"
}
// 전투원 배열을 "카운트|(11필드)*" 로 직렬화한다(v5). / Serialize an array of combatants as "count|(11 fields)*".
function 배열직렬(목록: Array<creature.전투원>) -> string {
let mut 결과 = "{목록.length}"
for 항목 in 목록 {
결과 = "{결과}|{전투원직렬(항목)}"
}
결과
}
// 계통 노드 한 개를 7필드로 직렬화한다(유아이디·부모유아이디·형태이름·게놈요약·세대·발견스텝·시드지문). 형태이름·게놈요약·시드지문은 파이프 없음.
// Serialize one lineage node as 7 fields. formName / genomeSummary / seedFingerprint are pipe-free.
function 노드직렬(노드: evolution.계통노드) -> string {
"{노드.유아이디}|{노드.부모유아이디}|{노드.형태이름}|{노드.게놈요약}|{노드.세대}|{노드.발견스텝}|{노드.시드지문}"
}
// 계통 노드 배열을 "카운트|(7필드)*" 로 직렬화한다. / Serialize the lineage-node array as "count|(7 fields)*".
function 노드배열직렬(목록: Array<evolution.계통노드>) -> string {
let mut 결과 = "{목록.length}"
for 항목 in 목록 {
결과 = "{결과}|{노드직렬(항목)}"
}
결과
}
// 처치된 엔티티 아이디 목록을 "카운트|id|id..." 로 직렬화한다(v6, 카운트 접두 → 자기경계). / Serialize cleared-entity ids as "count|id|id...".
function 처치직렬(목록: Array<int>) -> string {
let mut 결과 = "{목록.length}"
for 아이디 in 목록 {
결과 = "{결과}|{아이디}"
}
결과
}
// 진화 서브상태를 11토큰으로 직렬화한다(단계·부모형태·부모게놈·부모계통시드·부모진화카운터·부모세대·부모유아이디·선택커서·잠긴분기·결과형태·결과게놈).
// 게놈은 콜론 토큰, 형태/계통시드는 파이프 없음. 라운드트립 안정(진화 도중 새로고침해도 같은 후보를 재유도). / Serialize the evolution sub-state as 11 tokens.
function 진화직렬(진: 진화상태) -> string {
"{진.단계}|{진.부모형태}|{게놈직렬(진.부모게놈)}|{진.부모계통시드}|{진.부모진화카운터}|{진.부모세대}|{진.부모유아이디}|{진.선택커서}|{진.잠긴분기}|{진.결과형태}|{게놈직렬(진.결과게놈)}"
}
// 상태 → STATE 문자열(한 줄, v3). 필드 순서를 고정해 역직렬화가 같은 상태를 복원한다(멱등 라운드트립). 메시지는 마지막.
// State -> v3 STATE string (one line). Field order fixed so deserialize restores the SAME state. Message goes last.
export function 직렬화(에스: 상태) -> string {
let 버전 = 에스.버전
let 시드 = 에스.월드시드
let 스텝 = 에스.스텝
let 엑스 = 에스.플레이어.엑스
let 와이 = 에스.플레이어.와이
let 방향 = 에스.플레이어.방향
let 맵 = 에스.맵아이디
let 장면 = 에스.장면
let 야생종 = 에스.야생.종아이디
let 야생레벨 = 에스.야생.레벨
let 야생체력 = 에스.야생.체력
let 야생스프라이트 = 에스.야생.스프라이트
let 야생속성 = 에스.야생.속성
let 야생이름 = 에스.야생.이름
let 볼 = 에스.스포어볼
let 포획카 = 에스.포획카운터
let 파티부 = 배열직렬(에스.파티)
let 보관부 = 배열직렬(에스.보관함)
let 파티커서 = 에스.파티커서
let 도감커서 = 에스.도감커서 // P6: 도감 커서(파티커서 바로 뒤, 계통섹션 앞 — 전진 인덱스 1토큰) / dex cursor (one token right after the party cursor)
let 처치부 = 처치직렬(에스.처치된것들) // v6: 처치 엔티티 아이디(도감커서 뒤, 계통섹션 앞 — 카운트 접두) / cleared-entity ids (after dex cursor, before lineage)
let 초점값 = 에스.초점 // v7: 초점(키우는 동료 인덱스). 처치셋 뒤, 계통섹션 앞 — 단일 토큰. / v7: focused party index (after cleared set, before lineage)
let 스토리값 = "{에스.스토리.본것}:{에스.스토리.대기}" // v8: 스토리 진행(본것:대기) 한 토큰. 초점 뒤, 계통섹션 앞. / v8: story progress (seen:queue), one token, after focus, before lineage
let 계통부 = 노드배열직렬(에스.계통노드들) // P5: 계통 노드 리스트(파티커서와 배틀꼬리 사이) / lineage nodes between cursor and battle tail
let 진화부 = 진화직렬(에스.진화) // P5: 진화 서브상태(계통섹션과 배틀꼬리 사이, 고정 11토큰) / evolution sub-state (fixed 11 tokens, before the battle tail)
let 비단계 = 에스.배틀.단계
let 비메뉴 = 에스.배틀.메뉴커서
let 비기술 = 에스.배틀.기술커서
let 비카운터 = 에스.배틀.전투카운터
let 비적체력 = 에스.배틀.적체력
let 비메시지커서 = 에스.배틀.메시지커서
let 비다음 = 에스.배틀.다음단계
let 비결과 = 에스.배틀.결과
let 비메시지 = 에스.배틀.메시지
"v{버전}|{시드}|{스텝}|{엑스}|{와이}|{방향}|{맵}|{장면}|{야생종}|{야생레벨}|{야생체력}|{야생스프라이트}|{야생속성}|{야생이름}|{볼}|{포획카}|{파티부}|{보관부}|{파티커서}|{도감커서}|{처치부}|{초점값}|{스토리값}|{계통부}|{진화부}|{비단계}|{비메뉴}|{비기술}|{비카운터}|{비적체력}|{비메시지커서}|{비다음}|{비결과}|{비메시지}"
}
// "v3" 같은 버전 토큰에서 정수만 뽑는다(앞의 v를 떼고 toInt). / Strip a leading 'v' from a version token and parse the int.
function 버전파싱(토큰: string) -> int {
let mut 숫자 = ""
for 글 in 토큰.scalars() {
if 글 != "v" { 숫자 = 숫자 + 글 }
}
toInt(숫자) ?? 3
}
// 전투원 하나를 복원한다. 보폭(stride)=11 이면 v5(게놈·세대·진화카운터·형태이름·스탯부스트까지 읽음), 6 이면 v4(미진화로 마이그레이트),
// 4 이면 v3(레거시 — 계통시드를 마이그레이션시드로 결정론 부여, 드리프트 0, 미진화). 마이그레이션 경로(보폭<11)는 항상 미진화 형태(세대 0)다.
// Restore one combatant. stride=11 -> v5 (read genome / generation / evoCounter / formName / statBoost). stride=6 -> v4 (migrate as
// un-evolved). stride=4 -> v3 legacy (assign migration seed, drift 0, un-evolved). Any migration path (stride<11) yields an un-evolved (gen 0) form.
function 전투원파싱(부분: Array<string>, 시작: int, 보폭: int, 마이그레이션시드: string) -> creature.전투원 {
let 종 = toInt(부분[시작]) ?? 시작종아이디()
let 레벨 = toInt(부분[시작 + 1]) ?? 5
// 경험을 [0, 카운터상한] 으로 가둔다 — 경험적용의 경험 = 대상.경험 + 얻은경험(battle.tpz)이 오버플로하지 못하게(조작된 거대 경험 방어).
// Clamp exp so 경험적용's 경험 = 대상.경험 + 얻은경험 cannot overflow on a crafted huge exp. (전투원복원 clamps the rest.)
let 경험 = 카운터가두기(toInt(부분[시작 + 2]) ?? 0)
let 체력 = toInt(부분[시작 + 3]) ?? 0
let 계통시드 = if 보폭 >= 6 { 부분[시작 + 4] } else { 마이그레이션시드 }
let 드리프트 = if 보폭 >= 6 { toInt(부분[시작 + 5]) ?? 0 } else { 0 }
// P5(보폭 11): 게놈·세대·진화카운터·형태이름·스탯부스트를 토큰에서 읽는다. 보폭<11(v4/v3 마이그레이션)이면 미진화 기본값(세대 0·형태"" ·부스트 0).
// 게놈은 세대 0 일 때 전투원복원이 계통시드에서 재유도하므로(미진화 단일 소스), 마이그레이션 경로의 게놈 인자는 무시된다 → 빈 게놈을 넘겨도 안전.
// P5 (stride 11): read genome / gen / evoCounter / formName / statBoost. For stride<11 (v4/v3 migration) use un-evolved defaults
// (gen 0, formName "", boost 0). At gen 0 전투원복원 re-derives the genome from the lineage seed, so the migration genome arg is ignored.
let 세대 = if 보폭 >= 11 { toInt(부분[시작 + 7]) ?? 0 } else { 0 }
let 진화카운터 = if 보폭 >= 11 { toInt(부분[시작 + 8]) ?? 0 } else { 0 }
let 형태이름 = if 보폭 >= 11 { 부분[시작 + 9] } else { "" }
let 스탯부스트 = if 보폭 >= 11 { toInt(부분[시작 + 10]) ?? 0 } else { 0 }
let 기준게놈 = if 보폭 >= 11 { 게놈역직렬(부분[시작 + 6]) } else { genome.게놈유도(계통시드) }
// 전투원복원이 레벨[1,99]·체력[0,최대]·드리프트[0,상한]·세대[0,99]·진화카운터·부스트[0,400]를 가둔다 — 복원 값이 산술을 넘기지 못한다.
// 전투원복원 clamps level/HP/drift/generation/evoCounter/boost — restored values cannot overflow the math.
creature.전투원복원(종, 레벨, 경험, 체력, 계통시드, 드리프트, 세대, 진화카운터, 형태이름, 스탯부스트, 기준게놈)
}
// "카운트|(보폭필드)*" 를 전투원 배열로 복원한다. 보폭=6(v4) 또는 4(v3 마이그레이션). 시작 인덱스에서 카운트를 읽고 그만큼(또는 토큰이 부족하면 그만큼만) 파싱한다.
// 마이그레이션접두 = "party"/"box": v3 레거시 전투원에게 "월드시드#legacy:접두:인덱스" 계통시드를 결정론적으로 부여한다(같은 v3 → 같은 게놈).
// 최소항목수>0 이면 적어도 그만큼은 보장한다(파티는 항상 1마리 이상). 결과는 (배열·다음 인덱스). / Restore array; minItems floors the party at 1.
export type 배열파싱결과 = { 목록: Array<creature.전투원>, 다음: int }
function 배열파싱(부분: Array<string>, 시작: int, 최소항목수: int, 보폭: int, 월드시드: string, 마이그레이션접두: string) -> 배열파싱결과 {
// 범위 가드(자매 노드배열파싱과 동일): 카운트 토큰이 토큰 끝을 벗어나면 빈 목록으로 안전 복귀한다. 잘린 STATE(파티가 토큰을 모두 소비)에서
// 보관함 호출이 시작 = 부분.length 로 들어와 부분[시작] 이 배열 밖을 색인(TPZ4001)하던 하드 크래시를 막는다. 호출자가 빈 보관함을 안전히 처리한다.
// Range guard (mirrors 노드배열파싱): if the count token is past the end, return an empty list safely. Prevents the box call
// in a truncated STATE (party consumed all tokens) from indexing 부분[시작] out of bounds (TPZ4001). The empty box is safe.
if 시작 < 0 || 시작 >= 부분.length {
return { 목록: [], 다음: 시작 }
}
let mut 카운트 = toInt(부분[시작]) ?? 0
if 카운트 < 0 { 카운트 = 0 }
// 안전 상한: 토큰이 실제로 받쳐주는 항목 수를 넘지 않게(조작된 거대 카운트 방어). 보폭으로 나눈다(v4=6·v3=4). / Cap by tokens present (per stride).
let 가용 = (부분.length - (시작 + 1)) / 보폭
if 카운트 > 가용 { 카운트 = if 가용 < 0 { 0 } else { 가용 } }
let mut 목록: Array<creature.전투원> = []
let mut 인덱스 = 0
let mut 커서 = 시작 + 1
while 인덱스 < 카운트 {
// v3 레거시 마이그레이션 시드(보폭<6 일 때만 쓰임): "월드시드#legacy:party:i" 또는 ":box:j". 결정론적·인덱스 고유. / deterministic legacy seed.
let 마이그레이션시드 = "{월드시드}#legacy:{마이그레이션접두}:{인덱스}"
목록.push(전투원파싱(부분, 커서, 보폭, 마이그레이션시드))
커서 = 커서 + 보폭
인덱스 = 인덱스 + 1
}
// 최소 보장(파티): 비어 있으면 시작종 1마리를 지급해 빈 파티를 막는다. 계통시드는 시작 계통시드. / Floor: grant the starter (with the starter lineage seed).
if 목록.length < 최소항목수 {
목록 = [creature.종에서전투원(시작종아이디(), 5, genome.시작계통시드(월드시드))]
}
{ 목록: 목록, 다음: 커서 }
}
// 계통 노드 배열을 "카운트|(7필드)*" 로 복원한다(v5). 시작에서 카운트를 읽고, 토큰이 받쳐주는 만큼(상한배제 = 배틀꼬리 9토큰 전까지)만 파싱한다.
// 결과는 (노드리스트·다음 인덱스). 조작된 거대 카운트는 가용 항목 수로 가둔다(오버플로/행 방지). / Restore the lineage-node array as "count|(7 fields)*".
export type 노드배열파싱결과 = { 목록: Array<evolution.계통노드>, 다음: int }
// P6 하위호환 판정: 시작에서 계통카운트를 읽고 그 섹션(1 + 카운트*7 토큰)의 끝이 예상끝(진화블록 시작)과 정확히 일치하는지. 일치하면 도감커서 토큰이 끼어든
// 현행 v5 배치, 아니면 옛 v5(도감커서 없음). 카운트 토큰이 정수가 아니거나 음수면 false. 토큰을 소비하지 않는 순수 판정(읽기만).
// P6 back-compat probe: read a lineage count at 시작 and check whether its section (1 + count*7 tokens) ends EXACTLY at 예상끝 (the evolution
// block start). A match means the dex-cursor token is present (current v5); otherwise it's the older v5 (no dex cursor). Pure (read-only).
function 계통섹션끝일치(부분: Array<string>, 시작: int, 예상끝: int) -> bool {
if 시작 < 0 || 시작 >= 부분.length { return false }
let 카운트옵션 = toInt(부분[시작])
let 카운트 = 카운트옵션 ?? -1
if 카운트 < 0 { return false }
시작 + 1 + 카운트 * 7 == 예상끝
}
function 노드배열파싱(부분: Array<string>, 시작: int, 상한배제: int) -> 노드배열파싱결과 {
let 노드보폭 = 7
// 도감 파싱 하드캡 — 조작·손상된 거대 카운트도 유계 시간에 끝낸다. 레이아웃이 노드당 전체 리스트를 스캔(O(n^2))하므로
// 입력을 여기서 묶어야 한다. 실제 계통수는 훨씬 작다(완주해도 수십 개). / Hard cap so a crafted huge count parses in bounded time;
// the layout scans the whole list per node (O(n^2)), so the input must be bounded HERE. Real lineages are far smaller.
let 도감노드상한 = 128
if 시작 < 0 || 시작 >= 부분.length {
return { 목록: [], 다음: 시작 }
}
let mut 카운트 = toInt(부분[시작]) ?? 0
if 카운트 < 0 { 카운트 = 0 }
// 가용 = (상한배제 - (시작+1)) / 노드보폭 — 배틀꼬리(상한배제) 앞까지만. 조작된 거대 카운트 방어. / cap by tokens before the battle tail.
let 가용 = (상한배제 - (시작 + 1)) / 노드보폭
if 카운트 > 가용 { 카운트 = if 가용 < 0 { 0 } else { 가용 } }
if 카운트 > 도감노드상한 { 카운트 = 도감노드상한 }
let mut 목록: Array<evolution.계통노드> = []
let mut 인덱스 = 0
let mut 커서 = 시작 + 1
while 인덱스 < 카운트 {
let 유 = toInt(부분[커서]) ?? 0
let 부모유 = toInt(부분[커서 + 1]) ?? 0
let 이름 = 부분[커서 + 2]
let 요약 = 부분[커서 + 3]
let mut 세대 = toInt(부분[커서 + 4]) ?? 0
세대 = creature.세대가두기(세대) // 세대 [0,99] 로 가둠(조작 방어) / clamp generation
let 발견스텝 = toInt(부분[커서 + 5]) ?? 0
let 지문 = 부분[커서 + 6]
목록.push({ 유아이디: 유, 부모유아이디: 부모유, 형태이름: 이름, 게놈요약: 요약, 세대: 세대, 발견스텝: 발견스텝, 시드지문: 지문 })
커서 = 커서 + 노드보폭
인덱스 = 인덱스 + 1
}
{ 목록: 목록, 다음: 커서 }
}
// STATE 문자열 → Result<상태, 파싱오류>. packet boundary(main)가 Err를 기본상태로 fail-close한다.
// v3(가변)·v2(27필드 단일파티)·v1(14필드)·v0(7필드) 형식을 모두 받는다: 빠진 부분은 기본값으로 채워 하위호환을 유지한다.
// STATE string -> Result<State, ParseError>. The packet boundary fail-closes Err to defaultState(seed).
// "카운트|id|id..." 를 처치 아이디 배열로 복원한다(v6). 시작에서 카운트를 읽고 그만큼 파싱(토큰 부족·상한·음수 방어). 카운트는 [0,64]로 가둔다.
// Restore cleared ids from "count|id|id..." (v6). Read the count at 시작, then that many ids (defends short/oob/negative). Count clamped [0,64].
export type 파싱오류 = "empty" | "short"
export type 처치파싱결과 = { 목록: Array<int>, 다음: int }
export function 처치파싱(부분: Array<string>, 시작: int, 상한: int) -> 처치파싱결과 {
let mut 카운트 = toInt(부분[시작]) ?? 0
if 카운트 < 0 { 카운트 = 0 }
if 카운트 > 64 { 카운트 = 64 } // 엔티티 수보다 한참 큰 안전 상한(조작 STATE 방어) / safety cap well above the entity count
let mut 목록: Array<int> = []
let mut 본수 = 0
let mut 인덱스 = 시작 + 1
while 본수 < 카운트 && 인덱스 < 상한 && 인덱스 < 부분.length {
let 아이디 = toInt(부분[인덱스]) ?? -1
if 아이디 >= 0 && 아이디 <= 100000 { 목록.push(아이디) }
인덱스 = 인덱스 + 1
본수 = 본수 + 1
}
{ 목록: 목록, 다음: 인덱스 }
}
export function 역직렬화(문자열: string, 대체시드: string) -> Result<상태, 파싱오류> {
let 다듬 = 문자열.trim()
if 다듬 == "" { return Err("empty") }
let 부분 = 다듬.split("|")
if 부분.length < 7 { return Err("short") }
let 버전 = 버전파싱(부분[0])
let 시드 = 부분[1]
let 장면값: 장면 = if 부분.length >= 8 { 장면파싱(부분[7]) } else { "field" }
// 야생(적): [8..13]. 적 레벨도 [0,99]로 가둔다 — 조작된 거대 적 레벨이 배틀 데미지 산술에서 오버플로하지 않게(0=야생 없음 허용).
// Wild (enemy) [8..13]; clamp the enemy level to [0,99] too so a crafted huge level cannot overflow the battle math (0 = no wild).
let 야생 = if 부분.length >= 14 {
let mut 야생레벨 = toInt(부분[9]) ?? 0
if 야생레벨 < 0 { 야생레벨 = 0 }
if 야생레벨 > 99 { 야생레벨 = 99 }
{
종아이디: toInt(부분[8]) ?? 0,
레벨: 야생레벨,
// 야생 체력을 [0, 카운터상한] 으로 가둔다 — HP 바 표시·포획 산술에 들어가는 거대 조작 체력 방어. / clamp wild HP into a safe range (HP bar + capture math).
체력: 카운터가두기(toInt(부분[10]) ?? 0),
스프라이트: toInt(부분[11]) ?? 0,
속성: toInt(부분[12]) ?? 0,
이름: 부분[13]
}
} else {
빈야생()
}
// ── 배틀 서브상태(9필드): 항상 마지막 9토큰. 메시지는 파이프가 없는 마지막 토큰이므로 위치가 파티/보관함 길이와 무관하다. ──
// Battle sub-state (9 fields) = always the LAST 9 tokens; the message has no pipes, so its position is independent of party/box length.
// v2/v3 처럼 배틀이 직렬화된 형식이면 끝 9토큰을 배틀로 읽고, 짧은 v0/v1이면 빈배틀. / Read the tail-9 as battle when present, else empty.
let 배틀끝 = 부분.length
let 배틀있음 = 부분.length >= 23 // v2(27)·v3(>=25) 둘 다 배틀 9토큰을 끝에 가진다 / both v2 and v3 carry a 9-token battle tail
let 배틀 = if 배틀있음 {
let b = 배틀끝 - 9
{
단계: battle.단계파싱(부분[b]),
메뉴커서: toInt(부분[b + 1]) ?? 0,
기술커서: toInt(부분[b + 2]) ?? 0,
// 전투카운터를 가둔다 — 공격굴림/속도순서/도주판정의 카운터 + 1(battle.tpz)이 오버플로하지 못하게. / clamp the battle counter so its +1 advances cannot overflow.
전투카운터: 카운터가두기(toInt(부분[b + 3]) ?? 0),
// 적체력을 가둔다 — HP 바 표시·포획 산술에 들어가는 거대 조작 체력 방어. / clamp enemy HP (HP bar + capture math).
적체력: 카운터가두기(toInt(부분[b + 4]) ?? 0),
메시지커서: toInt(부분[b + 5]) ?? 0,
다음단계: battle.다음단계파싱(부분[b + 6]),
결과: battle.결과파싱(부분[b + 7]),
메시지: 부분[b + 8]
}
} else {
battle.빈배틀()
}
// ── v5/v4/v3 본문(스포어볼·포획카운터·파티N·보관함·파티커서·계통노드) 또는 v2 마이그레이션 또는 v0/v1 기본. ──
// v5/v4/v3 body (spore-balls, capture counter, party-N, box, party cursor, lineage nodes), or v2 migration, or v0/v1 defaults.
let mut 볼 = 시작스포어볼()
let mut 포획카 = 0
let mut 파티: Array<creature.전투원> = []
let mut 보관함: Array<creature.전투원> = []
let mut 파티커서 = 0
let mut 도감커서 = 0
let mut 계통노드들: Array<evolution.계통노드> = []
let mut 진화서브 = 빈진화()
let mut 처치된것들: Array<int> = [] // v6: 처치 엔티티 아이디. v5 이하는 빈 목록(마이그레이션). / cleared ids; empty for v5 and older (migration).
let mut 초점 = 0 // v7: 초점(키우는 동료 인덱스). v6 이하는 0(마이그레이션). / focused party index; 0 for v6 and older.
let mut 스토리본것 = 0 // v8: 스토리 본 비트셋. v7 이하는 0(마이그레이션). / story seen bits; 0 for v7 and older.
let mut 스토리대기 = 0 // v8: 스토리 대기 비트셋. / story queue bits.
if 버전 >= 3 && 부분.length >= 17 {
// v5/v4/v3: [14]볼 [15]포획카 [16]파티카운트... 보폭=11(v5: 게놈·세대·진화카운터·형태이름·부스트 포함) / 6(v4) / 4(v3 레거시).
// v5/v4/v3: [14]balls [15]captureCounter [16]partyCount...; stride=11 (v5) / 6 (v4) / 4 (v3 legacy).
let 보폭 = if 버전 >= 5 { 11 } else if 버전 >= 4 { 6 } else { 4 }
볼 = toInt(부분[14]) ?? 시작스포어볼()
if 볼 < 0 { 볼 = 0 }
// 포획카운터를 가둔다 — 포획판정의 새카운터 = 카운터 + 1(capture.tpz)이 오버플로하지 못하게. / clamp the capture counter so 포획판정's +1 cannot overflow.
포획카 = 카운터가두기(toInt(부분[15]) ?? 0)
let 파티결과 = 배열파싱(부분, 16, 1, 보폭, 시드, "party") // 파티는 최소 1마리 보장 / party floored at 1
파티 = 파티결과.목록
let 보관결과 = 배열파싱(부분, 파티결과.다음, 0, 보폭, 시드, "box")
보관함 = 보관결과.목록
// 파티커서: 보관함 끝 토큰(보관결과.다음). 범위·항목수로 가둔다. 배틀꼬리 위치와 무관하게 전진 인덱스로 읽는다(v5의 가변 계통섹션 대비).
// Party cursor: the token right after the box (forward index, independent of the battle-tail position — needed for v5's variable lineage section).
let 커서인덱스 = 보관결과.다음
if 커서인덱스 >= 0 && 커서인덱스 < 부분.length {
파티커서 = toInt(부분[커서인덱스]) ?? 0
}
파티커서 = 범위가두기(파티커서, 0, 파티.length - 1)
// 계통 노드 + 진화 서브상태: 현행 v5 는 파티커서 다음에 "도감커서 | 계통카운트|(7필드)* | 진화(11토큰)" 가 온다(그 뒤 배틀꼬리 9토큰).
// 진화(11)는 배틀꼬리 바로 앞 고정 길이라 배틀끝-20 에서 읽고, 계통노드는 그 앞(상한배제)까지만 파싱한다. v4/v3 은 진화섹션 없음(빈진화).
// P6 하위호환: 이전 v5(도감커서 없음)는 파티커서 다음이 바로 계통카운트다. 도감토큰 자리에서 계통섹션이 진화블록까지 딱 맞아떨어지는지로 두 배치를 구별한다.
// Lineage nodes + evolution sub-state: the CURRENT v5 carries "dexCursor | lineageCount|(7 fields)* | evolution(11 tokens)" after the party
// cursor, then the 9-token battle tail. P6 back-compat: an OLDER v5 (no dexCursor) has the lineage count right after the party cursor.
// We disambiguate by whether the lineage section parsed from the dex-token slot lands exactly on the evolution block.
if 버전 >= 6 {
// ── v6: 도감커서(커서인덱스+1) → 처치셋(카운트 접두) → 계통노드 → 진화(11)·배틀(9) 끝고정. 고정 전진 배치라 v5 의 도감 추정 해킹이 필요 없다. ──
// v6: dexCursor (커서인덱스+1) -> cleared-set (count-prefixed) -> lineage -> evolution(11)+battle(9) end-anchored. Fixed front layout, no v5 dex-guess hack.
let 진화시작인덱스 = if 배틀있음 { 배틀끝 - 9 - 11 } else { 배틀끝 - 11 }
let 도감토큰인덱스 = 커서인덱스 + 1
if 도감토큰인덱스 >= 0 && 도감토큰인덱스 < 부분.length {
도감커서 = toInt(부분[도감토큰인덱스]) ?? 0
if 도감커서 < 0 { 도감커서 = 0 }
}
let 처치결과 = 처치파싱(부분, 커서인덱스 + 2, 진화시작인덱스)
처치된것들 = 처치결과.목록
// v7: 처치셋 바로 뒤에 초점(단일 토큰). v6 은 없으므로 계통이 바로 이어진다. / v7 reads the focus token right after the cleared set; v6 has none.
let mut 계통시작 = 처치결과.다음
if 버전 >= 7 {
if 처치결과.다음 >= 0 && 처치결과.다음 < 부분.length { 초점 = toInt(부분[처치결과.다음]) ?? 0 }
계통시작 = 처치결과.다음 + 1
// v8: 초점 바로 뒤 스토리 토큰("본것:대기"). v7 은 없으므로 계통이 바로 이어진다. / v8 reads the story token right after focus; v7 has none.
if 버전 >= 8 {
let 스토리인덱스 = 처치결과.다음 + 1
if 스토리인덱스 >= 0 && 스토리인덱스 < 부분.length {
let 스토리조각 = 부분[스토리인덱스].split(":")
if 스토리조각.length >= 2 {
스토리본것 = 비트가두기(toInt(스토리조각[0]) ?? 0)
스토리대기 = 비트가두기(toInt(스토리조각[1]) ?? 0)
}
}
계통시작 = 처치결과.다음 + 2
}
}
let 상한배제 = if 진화시작인덱스 >= 계통시작 { 진화시작인덱스 } else { 배틀끝 }
let 노드결과 = 노드배열파싱(부분, 계통시작, 상한배제)
계통노드들 = 노드결과.목록
if 진화시작인덱스 >= 0 && 진화시작인덱스 + 10 < 부분.length {
let e = 진화시작인덱스
진화서브 = {
단계: 진화단계파싱(부분[e]), 부모형태: 부분[e + 1], 부모게놈: 게놈역직렬(부분[e + 2]), 부모계통시드: 부분[e + 3],
부모진화카운터: creature.진화카운터가두기(toInt(부분[e + 4]) ?? 0), 부모세대: creature.세대가두기(toInt(부분[e + 5]) ?? 0),
부모유아이디: toInt(부분[e + 6]) ?? 0, 선택커서: toInt(부분[e + 7]) ?? 0, 잠긴분기: toInt(부분[e + 8]) ?? 0,
결과형태: 부분[e + 9], 결과게놈: 게놈역직렬(부분[e + 10])
}
}
} else if 버전 >= 5 {
let 진화시작인덱스 = if 배틀있음 { 배틀끝 - 9 - 11 } else { 배틀끝 - 11 }
// 도감커서가 있는 현행 배치인지 판정: 파티커서+1 을 도감커서로 보고 계통섹션을 파티커서+2 에서 읽었을 때 끝이 진화블록(진화시작인덱스)과 정확히 일치하면 현행 v5.
// Decide whether the dex-cursor token is present: if treating 커서인덱스+1 as the dex cursor and the lineage section at 커서인덱스+2 ends EXACTLY at the evolution block, it's the current v5.
let 도감있음 = (커서인덱스 + 1 < 부분.length) && 계통섹션끝일치(부분, 커서인덱스 + 2, 진화시작인덱스)
let 도감토큰인덱스 = 커서인덱스 + 1
if 도감있음 && 도감토큰인덱스 >= 0 && 도감토큰인덱스 < 부분.length {
도감커서 = toInt(부분[도감토큰인덱스]) ?? 0
if 도감커서 < 0 { 도감커서 = 0 }
}
let 계통시작 = if 도감있음 { 커서인덱스 + 2 } else { 커서인덱스 + 1 }
let 상한배제 = if 진화시작인덱스 >= 계통시작 { 진화시작인덱스 } else { 배틀끝 } // 진화섹션 앞까지만 계통노드 / lineage stops before the evolution block
let 노드결과 = 노드배열파싱(부분, 계통시작, 상한배제)
계통노드들 = 노드결과.목록
// 진화 서브상태(고정 11토큰): 단계·부모형태·부모게놈·부모계통시드·부모진화카운터·부모세대·부모유아이디·선택커서·잠긴분기·결과형태·결과게놈.
// The evolution sub-state (fixed 11 tokens). Read only when the block is fully present (defends short/crafted input).
if 진화시작인덱스 >= 0 && 진화시작인덱스 + 10 < 부분.length {
let e = 진화시작인덱스
진화서브 = {
단계: 진화단계파싱(부분[e]),
부모형태: 부분[e + 1],
부모게놈: 게놈역직렬(부분[e + 2]),
부모계통시드: 부분[e + 3],
부모진화카운터: creature.진화카운터가두기(toInt(부분[e + 4]) ?? 0),
부모세대: creature.세대가두기(toInt(부분[e + 5]) ?? 0),
부모유아이디: toInt(부분[e + 6]) ?? 0,
선택커서: toInt(부분[e + 7]) ?? 0,
잠긴분기: toInt(부분[e + 8]) ?? 0,
결과형태: 부분[e + 9],
결과게놈: 게놈역직렬(부분[e + 10])
}
}
}
} else if 부분.length >= 18 {
// v2 마이그레이션: 단일 파티 [14..17](4필드) → 1마리 파티. 레거시 계통시드를 결정론적으로 부여(보폭 4), 드리프트 0. 보관함 빈 채.
// v2 migration: single 4-field party [14..17] -> a 1-creature party; assign a deterministic legacy lineage seed (stride 4), drift 0; empty box.
파티 = [전투원파싱(부분, 14, 4, "{시드}#legacy:party:0")]
보관함 = []
} else {
// v0/v1: 파티 토큰 없음 → 시작종 1마리 지급(시작 계통시드). / v0/v1: no party tokens -> grant the starter (with the starter lineage seed).
파티 = [creature.종에서전투원(시작종아이디(), 5, genome.시작계통시드(시드))]
보관함 = []
}
// 스텝을 [0, 카운터상한] 으로 가둔다 — 이동의 새스텝 = 스텝 + 1(main.tpz)이 오버플로하지 못하게(조작된 거대 스텝 방어).
// Clamp step to [0, 카운터상한] so 이동's 새스텝 = step + 1 cannot overflow on a crafted huge step.
let 스텝값 = 카운터가두기(toInt(부분[2]) ?? 0)
// ── v4→v5(및 v3/v2/v1/v0) 마이그레이션: 계통섹션이 없던 옛 상태는 계통노드 리스트가 비어 있다. 비어 있으면 현재 크리처들(파티+보관함)의
// 원형(세대 0) 노드로 씨앗을 뿌린다 — 진화하지 않았어도 계통수의 뿌리가 있어야 P6 도감이 그릴 수 있다(지시서: "시작 형태로 씨앗"). 멱등.
// v4->v5 (and v3/v2/...) migration: old states had no lineage section, so the node list is empty. If empty, seed it with the current
// creatures' (party+box) gen-0 ROOT nodes — even un-evolved, the tree needs a root for P6's dex. (Directive: "seeded with their starting form.") Idempotent.
let mut 노드들 = 계통노드들
if 노드들.length == 0 {
let mut 씨앗: Array<evolution.계통노드> = []
for 원 in 파티 { 씨앗.push(시작노드(원, 스텝값)) }
for 원 in 보관함 { 씨앗.push(시작노드(원, 스텝값)) }
노드들 = 씨앗
}
// P6: 도감 커서를 계통 노드 범위로 가둔다(조작/범위 밖 방어). 노드가 비면 0. 노드 수가 줄어든 STATE 도 안전하게 클램프. / clamp dex cursor into the node range.
도감커서 = if 노드들.length > 0 { 범위가두기(도감커서, 0, 노드들.length - 1) } else { 0 }
// 초점을 파티 범위로 가둔다(파티가 줄어든·조작된 STATE 방어). 파티는 항상 1마리 이상이라 [0, 파티수-1]. / clamp the focus into the party range.
초점 = 범위가두기(초점, 0, 파티.length - 1)
Ok({
// 인메모리 상태는 항상 현재 형식(v8)이다. 파싱한 버전(버전)은 분기에만 쓰고, 결과는 8로 고정해
// 재직렬화가 늘 "v8|..."(v8 body)로 나가게 한다 — 안 그러면 마이그레이션된 v7/v6/...가 옛 토큰+v8 body로 손상된다.
// The in-memory state is always the current format (v8). The parsed version is used only for branching;
// pin the result to 8 so re-serialize always emits "v8|..." (a migrated older state must not keep its old token).
버전: 8,
월드시드: 시드,
스텝: 스텝값,
// 플레이어 좌표를 [0, 좌표상한] 으로 가둔다 — 드로우리스트의 (엑스-camx)*타일크기 곱셈과 이동의 새엑스/새와이 ±1 덧셈을 보호한다(조작된 거대 좌표 방어).
// Clamp player coordinates so the drawlist (x-camx)*16 multiply and 이동's ±1 add cannot overflow on a crafted huge coordinate.
플레이어: {
엑스: 좌표가두기(toInt(부분[3]) ?? 3),
와이: 좌표가두기(toInt(부분[4]) ?? 5),
방향: 방향파싱(toInt(부분[5]) ?? 0)
},
맵아이디: 부분[6],
장면: 장면값,
야생: 야생,
스포어볼: 볼,
포획카운터: 포획카,
파티: 파티,
보관함: 보관함,
파티커서: 파티커서,
도감커서: 도감커서,
처치된것들: 처치된것들,
초점: 초점,
스토리: { 본것: 스토리본것, 대기: 스토리대기 },
계통노드들: 노드들,
진화: 진화서브,
배틀: 배틀
})
}
// ===== core/drawlist.tpz =====
// 드로우리스트 빌더: 순수 상태 → 컴팩트 드로우리스트(문자열). 토파즈는 그릴 수 없으므로 "무엇을 그릴지"만 기술한다.
// JS 셸이 이 리스트를 파싱해 160x144 캔버스에 픽셀로 칠한다. 장면(field/battle)에 따라 다른 리스트를 낸다.
// Draw-list builder: pure state -> a compact draw-list (string). Topaz cannot draw, so it only DESCRIBES what to draw.
// The JS shell parses this list and paints pixels onto a 160x144 canvas. The list differs by scene (field / battle).
//
// 하우스룰(core): export는 타입/함수/불변 최상위만. 최상위 가변·print·input·host 금지(지역 let mut는 허용).
// House rule (core): export only types/functions/immutable bindings; no module-level mutable state/print/input/host.
//
// 타일 크기 = 16px. 화면 160x144 → 가시 10x9 타일(160/16=10, 144/16=9). 카메라는 플레이어 중심, 맵 경계로 클램프.
// Tile size = 16px. Screen 160x144 -> 10x9 visible tiles. Camera centers on the player, clamped to map bounds.
//
// 드로우리스트 문법(DRAW 블록 아래, 한 줄에 디렉티브 하나):
// DL1 버전 머리
// PAL|0|#hex,#hex,#hex,#hex 4색 팔레트(인덱스 0)
// BG|<맵아이디>|<camx>|<camy> 배경/카메라(필드 타일맵)
// TILES|w|h|<csv 타일아이디> 가시 윈도(필드, 10x9)
// BATTLEBG|<style> 배틀 배경(JS가 그라데이션+플랫폼 그림) ← P1 추가
// CSPR|<spr>|<x>|<y>|<flip>|<z> 적 크리처 스프라이트(배틀, 종 스프라이트 키) ← P1 추가
// ALLYSPR|<spr>|<x>|<y> 플레이어 크리처 뒷모습(배틀, 좌하단) ← P2 추가
// HPBAR|<x>|<y>|<w>|<cur>|<max> HP 바(JS가 비율로 녹/노/적 칠함) ← P2 추가
// MENU|<cursor>|<opt0>;<opt1>;.. 커맨드/기술 메뉴(커서 강조, ';' 구분 옵션) ← P2 추가
// SPR|id|tile|x|y|flip|pal|z 플레이어 스프라이트(필드)
// TXT|x|y|w|<text> 텍스트(픽셀 좌표, 폭=픽셀)
// BOX|x|y|w|h|style 박스(GBC 텍스트박스/HP박스; style 1=HP박스) ← style 1 P1 추가
// DRIFT|<owner>|<drift>|<pct>|<ready>|<원본게놈csv>|<표현형게놈csv> 드리프트 미터 + 게놈/표현형(외형 너지) ← P4 추가
// owner = "party:<i>"(파티뷰 행) 또는 "ally"(배틀 아군). pct = 진화임계 대비 0..100. ready = 진화 준비 1/0.
// JS가 pct로 드리프트 막대를 그리고, 표현형(원본+드리프트 너지)으로 블롭의 색조/모양을 미세하게 흔든다(느린 진화 미리보기).
// DRIFT carries the drift meter (pct vs. threshold) + the original genome + the drifted phenotype; JS renders the bar and the subtle blob nudge.
//
// Key identifiers: 타일크기=tileSize, 가시너비/가시높이=viewW/viewH, 카메라엑스/와이=cameraX/Y,
// 타일윈도=tileWindow, 드로우리스트=buildDrawList, 필드드로우=fieldDraw, 배틀드로우=battleDraw.
import data.maps { 맵너비, 맵높이, 맵타일들, 타일에서, 워프목록 }
import data.palettes { 연못팔레트 }
import data.species { 속성이름, 종찾기 }
import data.entities { 엔티티목록, 활성엔티티에서, 처치포함, 엔티티_야생, 엔티티_NPC }
import data.moves { 기술찾기 }
import core.creature // §17: 전투원 타입을 한정 참조(creature.전투원)하므로 모듈째 import. 최대체력도 creature.최대체력 로 호출. / whole-module import (qualified 전투원 type + creature.최대체력).
import core.battle { 메시지줄 }
import core.genome { 표현형, 게놈요약, 게놈요약파싱, 드리프트퍼센트, 진화준비, 출처, 출처키 }
import core.evolution // §17: 진화 후보 타입을 한정 참조(evolution.후보)하므로 모듈째 import. / whole-module import for the qualified 후보 type (evolve-scene menu).
import core.lineagedex // §17: 배치노드/계통레이아웃 타입 한정 참조 + 레이아웃계산. P6 도감 트리. / whole-module import (P6 dex tree layout).
import core.auto { 자동목표 } // 영화 폴리시: 필드 HUD에 띄울 "지금 자동이 하는 일" 한 줄. / the auto objective line for the field HUD.
import core.story { 최하위비트, 비트내레이션, 비트있음, 비트_첫포획, 비트_첫심층, 비트_정복 } // 슬라이스3 카드 + 연속성 패스: NPC가 스토리 진행(본 비트)에 반응. / story card + NPCs that echo progress.
import core.clash { 우세형질, 적게놈유도 } // 압력전 감상: 이번 비트에 양측이 발동한 우세 형질(애니용). / clash watchability: each side's dominant trait this beat (for FX).
import core.state
import core.util { 범위가두기 }
let 타일크기 = 16
let 가시너비 = 10 // 160 / 16
let 가시높이 = 9 // 144 / 16
// 한 전투원의 DRIFT 디렉티브 줄을 만든다(P4 가시 미리보기). owner = "party:i" 또는 "ally".
// 원본 게놈 + 표현형(원본 + 드리프트 너지) + 드리프트 퍼센트 + 진화 준비 플래그를 한 줄로 내린다. JS가 미터와 외형 너지를 그린다.
// Build one combatant's DRIFT directive line (P4 visible preview). owner = "party:i" or "ally". Carries the original genome,
// the phenotype (original + drift nudge), the drift percent, and the ready flag — the JS shell renders the meter + appearance nudge.
function 드리프트줄(주인: string, 전투원: creature.전투원) -> string {
let 표 = 표현형(전투원.게놈, 전투원.계통시드, 전투원.드리프트) // 표현형 = 원본 + 드리프트 너지(결정론) / phenotype = original + drift nudge
let 퍼센트 = 드리프트퍼센트(전투원.드리프트)
let 준비 = if 진화준비(전투원.드리프트) { 1 } else { 0 }
"DRIFT|{주인}|{전투원.드리프트}|{퍼센트}|{준비}|{게놈요약(전투원.게놈)}|{게놈요약(표)}"
}
// 초점(키우는 중) 전투원 = 지금 배틀·진화하는 한 마리. 초점은 역직렬화에서 가둬지지만 방어적으로 한 번 더. 필드 HUD·배틀·진화 렌더가 이걸 본다.
// The focused creature = the one that currently battles/evolves; the field HUD, battle, and evolution renders all use this (defensive clamp).
function 초점전투원(에스: state.상태) -> creature.전투원 {
let f = 범위가두기(에스.초점, 0, 에스.파티.length - 1)
에스.파티[f]
}
// 카메라 좌상단(타일 단위): 플레이어를 화면 중앙에 두되 맵 경계로 클램프해 빈 곳이 안 보이게 한다.
// Camera top-left (tile units): center the player, clamped to map bounds so no off-map area shows.
function 카메라엑스(플레이어엑스: int) -> int {
범위가두기(플레이어엑스 - (가시너비 / 2), 0, 맵너비 - 가시너비)
}
function 카메라와이(플레이어와이: int) -> int {
범위가두기(플레이어와이 - (가시높이 / 2), 0, 맵높이 - 가시높이)
}
// 가시 윈도의 타일 아이디를 행 우선 CSV로 만든다(10x9 = 90개). 범위 밖은 타일에서()가 나무 벽으로 메운다.
// Build the visible window's tile ids as a row-major CSV (10x9 = 90 entries). Out-of-range is filled by the tree wall.
function 타일윈도(타일들: Array<int>, camx: int, camy: int) -> string {
let mut 결과 = ""
let mut 행 = 0
while 행 < 가시높이 {
let mut 열 = 0
while 열 < 가시너비 {
if !(행 == 0 && 열 == 0) { 결과 = 결과 + "," }
let 아이디 = 타일에서(타일들, camx + 열, camy + 행)
결과 = 결과 + "{아이디}"
열 = 열 + 1
}
행 = 행 + 1
}
결과
}
// 필드 장면 드로우리스트: DL1 / PAL / BG / TILES / 플레이어 SPR / 화면 안 엔티티 ENT / (마주 본 NPC면 대화 BOX+TXT).
// Field-scene draw list: DL1 / PAL / BG / TILES / player SPR / on-screen entity ENT lines / (a faced NPC adds a dialogue BOX+TXT).
function 필드드로우(에스: state.상태, 팔레트: string) -> string {
let 타일들 = 맵타일들(에스.맵아이디)
let camx = 카메라엑스(에스.플레이어.엑스)
let camy = 카메라와이(에스.플레이어.와이)
let 윈도 = 타일윈도(타일들, camx, camy)
// 플레이어 스프라이트의 화면 픽셀 좌표 = (맵타일 - 카메라좌상) * 16. / Player screen px = (tile - camTopLeft) * 16.
let 스크린엑스 = (에스.플레이어.엑스 - camx) * 타일크기
let 스크린와이 = (에스.플레이어.와이 - camy) * 타일크기
let 방향 = 에스.플레이어.방향
let 맵아이디 = 에스.맵아이디
// 스프라이트 tile = 방향(JS가 4방향 패턴으로 그림). z=10으로 타일 위. flip=0, pal=0.
let 플레이어줄 = "SPR|0|{방향}|{스크린엑스}|{스크린와이}|0|0|10"
// 화면 안의 엔티티만 ENT 줄로 내린다. ENT|종류|스프라이트|sx|sy (0 야생 → 종 블롭, 1 NPC → 사람 모양). 화면 밖은 생략(카메라 윈도).
// Emit only on-screen entities as ENT lines (0 wild -> species blob, 1 NPC -> a person). Off-screen entities are skipped (camera window).
let mut 엔티티줄들 = ""
for 이 in 엔티티목록(에스.맵아이디) {
// 처치된(잡거나 이긴) 야생은 그리지 않는다 — 맵에서 사라진다. / cleared (caught/defeated) wilds are not drawn; they vanish.
let 보임 = 이.엑스 >= camx && 이.엑스 < camx + 가시너비 && 이.와이 >= camy && 이.와이 < camy + 가시높이 && !처치포함(에스.처치된것들, 이.아이디)
if 보임 {
let 스프 = if 이.종류 == 엔티티_야생 { 종찾기(이.종아이디).스프라이트 } else { 0 }
let ex = (이.엑스 - camx) * 타일크기
let ey = (이.와이 - camy) * 타일크기
엔티티줄들 = "{엔티티줄들}\nENT|{이.종류}|{스프}|{ex}|{ey}"
}
}
// 마주 본 칸이 NPC면 하단 대화 박스 + 한마디(상태 변화 없이 마주 섰을 때만 보인다). 0아래·1위·2왼·3오른.
// If the faced tile holds an NPC, add a bottom dialogue box + line (stateless: shown only while facing). dir 0 down /1 up /2 left /3 right.
let 칸엑스 = if 방향 == 2 { 에스.플레이어.엑스 - 1 } else if 방향 == 3 { 에스.플레이어.엑스 + 1 } else { 에스.플레이어.엑스 }
let 칸와이 = if 방향 == 1 { 에스.플레이어.와이 - 1 } else if 방향 == 0 { 에스.플레이어.와이 + 1 } else { 에스.플레이어.와이 }
let 앞 = 활성엔티티에서(에스.맵아이디, 칸엑스, 칸와이, 에스.처치된것들)
// NPC 대사는 진행에 반응한다: 아직 한 마리도 못 잡았으면 안내, 한 마리라도 처치했으면 칭찬 + 다음 안내(상호작용 기반 스토리 전개의 씨앗).
// The NPC line reacts to progress: a tutorial before the first clear, praise + a next hint after (the seed of interaction-driven story).
// 연속성 패스: NPC가 스토리 진행(본 비트)에 반응한다 — 세계가 살아 있게. 가장 멀리 간 마일스톤을 우선해 한마디. 아직이면 기존 안내/저자 대사.
// Continuity pass: NPCs echo story progress (seen bits) so the world feels alive — the furthest milestone wins. Before any, the tutorial / authored line.
let npc대사 = if 비트있음(에스.스토리.본것, 비트_정복) {
"숲이 다시 숨을 쉽니다. 당신이 천천히 지켜낸 결과입니다. 푹 쉬어 가십시오."
} else if 비트있음(에스.스토리.본것, 비트_첫심층) {
"깊은 곳을 보셨군요. 그곳은 적의 땅이 아니라, 멈춰 버린 생명입니다. 회복하고 다시 내려가십시오."
} else if 비트있음(에스.스토리.본것, 비트_첫포획) {
"한 가닥의 생명을 돌보기 시작하셨군요. 서두르지 마십시오. A로 회복합니다. 깊은 곳일수록 더 강한 미생물이 기다립니다."
} else { 앞.대사 }
let 대화줄 = if 앞.종류 == 엔티티_NPC { "\nBOX|6|104|148|34|0\nTXT|12|112|136|{npc대사}" } else { "" }
// 야생을 마주 보면 그 칸 위에 "A" 프롬프트(누르면 배틀). 누를 곳을 분명히 알려 준다. NPC는 대화 박스가 곧 어포던스라 생략.
// Facing a wild draws an "A" prompt above its tile so the player knows where to press A. NPCs skip it (their dialogue box is the affordance).
let 프롬프트줄 = if 앞.종류 == 엔티티_야생 { "\nPROMPT|{(칸엑스 - camx) * 타일크기}|{(칸와이 - camy) * 타일크기}" } else { "" }
// 필드 HUD: 선두 동료의 드리프트(다음 진화까지의 느린 진척)를 작은 미터로 보여 준다 — 걷는 동안 "진화가 가까워진다"가 느껴지게(브랜드: slow).
// Field HUD: the lead creature's drift toward the next evolution as a small meter, so the slow climb is felt while exploring (brand: slow).
let HUD줄 = if 에스.파티.length > 0 { "\n{드리프트줄("field", 초점전투원(에스))}" } else { "" }
// 워프(생물군계 전이) 칸: 화면 안이면 WARP|sx|sy 로 내린다. JS가 소용돌이 포털을 그려 "여기로 들어가면 다른 곳"임을 알린다. / on-screen warps.
let mut 워프줄들 = ""
for w in 워프목록(에스.맵아이디) {
let 보임 = w.엑스 >= camx && w.엑스 < camx + 가시너비 && w.와이 >= camy && w.와이 < camy + 가시높이
if 보임 {
워프줄들 = "{워프줄들}\nWARP|{(w.엑스 - camx) * 타일크기}|{(w.와이 - camy) * 타일크기}"
}
}
// 자동 목표(영화 폴리시): 지금 자동이 무엇을 하는지 한 줄. JS 가 자동 켜졌을 때만 HUD 로 띄운다(꺼져 있으면 무시) → "보는 게임"임이 읽힌다.
// Auto objective (movie polish): what auto is doing now; the JS shows it as a HUD only while auto is on. Makes the run read as a watchable game.
let 목표줄 = "\nOBJECTIVE|{자동목표(에스)}"
"DL1\nPAL|0|{팔레트}\nBG|{맵아이디}|{camx}|{camy}\nTILES|{가시너비}|{가시높이}|{윈도}\n{플레이어줄}{워프줄들}{엔티티줄들}{대화줄}{프롬프트줄}{HUD줄}{목표줄}"
}
// 배틀 장면 드로우리스트(§1.5 레이아웃·P2 플레이 가능).
// 적 HP/이름 박스(좌상단) + 적 크리처(우상단) · 플레이어 HP/이름 박스(우하단 위) + 플레이어 뒷모습(좌하단) ·
// 하단 메시지 박스 + 단계별 내용: message=현재 줄 · menu=커맨드(싸운다/도망간다) · move=기술 목록(커서).
// Battle-scene draw list (§1.5, PLAYABLE in P2): enemy HP box (top-left) + enemy sprite (top-right);
// player HP box (above bottom-right) + player back sprite (bottom-left); bottom message box whose content varies by phase
// (message=current line, menu=FIGHT/RUN command menu, move=move list with cursor).
function 배틀드로우(에스: state.상태, 팔레트: string) -> string {
let 야생 = 에스.야생
let 파티 = 초점전투원(에스) // 배틀에 나선 = 초점(키우는 중) 전투원 / the battler = the focused creature
let 비 = 에스.배틀
// 적(야생) 정보. 적 최대체력은 레벨 스케일(creature.최대체력), 현재체력은 배틀.적체력. / Enemy stats; current HP from battle state.
let 적이름 = 야생.이름
let 적레벨 = 야생.레벨
let 적스프라이트 = 야생.스프라이트
let 적속성 = 속성이름(야생.속성)
let 적종 = 종찾기(야생.종아이디)
let 적최대 = creature.최대체력(적종, 적레벨)
let 적현재 = 비.적체력
// 플레이어 크리처 정보. / Player creature stats.
let 파티종 = 종찾기(파티.종아이디)
let 파티이름 = 파티종.이름
let 파티레벨 = 파티.레벨
let 파티스프라이트 = 파티.스프라이트
let 파티속성 = 속성이름(파티.속성)
let 파티최대 = 파티.최대체력
let 파티현재 = 파티.현재체력
// 삼투 압력전 배경 + 두 세포. 적=우상단(CSPR), 플레이어=좌하단(ALLYSPR). / osmosis clash: background + the two cells.
let 배경줄 = "BATTLEBG|0"
let 적줄 = "CSPR|{적스프라이트}|104|22|0|5" // 야생(적) 레코드엔 게놈이 없어 종 색 블롭으로 둔다. / wild record has no genome; species-colored blob.
let 아군줄 = "ALLYSPR|{파티스프라이트}|18|72"
// 적 안정도(cohesion) 박스(좌상단). HP 바를 안정도 바로 재사용. / foe cohesion box (HP bar reused as cohesion).
let 적박스줄 = "BOX|4|4|92|30|1"
let 적이름줄 = "TXT|8|8|84|{적이름}"
let 적레벨줄 = "TXT|8|17|84|Lv {적레벨} 안정도"
let 적바줄 = "HPBAR|8|27|80|{적현재}|{적최대}"
// 플레이어 안정도 박스(메시지박스 위, 우). / player cohesion box.
let 파티박스줄 = "BOX|64|62|92|30|1"
let 파티이름줄 = "TXT|68|66|84|{파티이름}"
let 파티레벨줄 = "TXT|68|75|84|Lv {파티레벨} 안정도"
let 파티바줄 = "HPBAR|68|85|68|{파티현재}|{파티최대}"
// P4: 아군 드리프트 줄(진화까지의 진척 + 블롭 너지). / ally drift line (progress to evolution + blob nudge).
let 드리프트줄아군 = 드리프트줄("ally", 파티)
// 압력 기울기 메터 — 삼투 압력전의 심장. -100..100, 중앙=중립. + = 내가 민다. JS가 가운데 기준 좌우로 차는 막대로 그린다.
// The pressure-gradient meter (the heart of the clash): -100..100, centre = neutral, + = I push. The JS draws a centre-anchored bar.
let 압력줄 = "PRESSURE|{비.메뉴커서}"
// 이번 비트에 양측이 발동한 우세 형질(0색소·1막·2크기·3대사·4촉수). JS가 발동한 세포에 형질색 플래시를 입힌다. / dominant trait fired each side; the JS flashes the firing cell in the trait's colour.
let 내형질 = 우세형질(표현형(파티.게놈, 파티.계통시드, 파티.드리프트))
let 적형질 = 우세형질(적게놈유도(야생.종아이디, 적레벨, 에스.월드시드))
let 형질줄 = "BEATFX|{내형질}|{적형질}"
// 하단 박스 — 이번 비트의 형질 사건(들) 또는 결과 문구. " · " 로 나뉜 두 사건은 두 줄로. / bottom box: this beat's trait event(s) or the result line; " · " splits into two lines.
let 메시지박스줄 = "BOX|0|104|160|40|0"
let 줄들 = 비.메시지.split(" · ")
let mut 본문 = ""
let mut 본문와이 = 114
for l in 줄들 {
본문 = "{본문}\nTXT|8|{본문와이}|148|{l}"
본문와이 = 본문와이 + 12
}
"DL1\nPAL|0|{팔레트}\n{배경줄}\n{적줄}\n{아군줄}\n{적박스줄}\n{적이름줄}\n{적레벨줄}\n{적바줄}\n{파티박스줄}\n{파티이름줄}\n{파티레벨줄}\n{파티바줄}\n{드리프트줄아군}\n{압력줄}\n{형질줄}\n{메시지박스줄}{본문}"
}
// ── 파티뷰 드로우리스트(GBC 파티 스크린): 제목 박스 + 파티 목록(각 줄: 이름·Lv·HP/maxHP·속성, 블롭 썸네일) + 커서 + 닫기 안내. ──
// Party-view draw list (GBC party screen): a title box + a per-creature list (name, Lv, HP/maxHP, type, blob thumbnail) + a cursor + a close hint.
// 새 디렉티브: PTITLE(제목 줄) · PROW(한 마리 줄: 인덱스·선택여부·스프라이트·이름·Lv·현재·최대·속성) · PCURSOR(커서 인덱스). JS가 GBC식으로 그린다.
// New directives: PTITLE (title) · PROW (one creature row) · PCURSOR (cursor index). The JS shell paints them GBC-style.
function 파티드로우(에스: state.상태, 팔레트: string) -> string {
let 파티 = 에스.파티
// 제목 박스 + 보관함 수 안내. / Title box + a box-count hint.
let mut 줄들 = "DL1\nPAL|0|{팔레트}\nBOX|0|0|160|144|0\nPTITLE|파티 ({파티.length}/{state.파티최대}) 보관함 {에스.보관함.length}"
// 각 파티원 한 줄. PROW|<index>|<selected 0/1>|<spr>|<name>|<lv>|<curHp>|<maxHp>|<typeName>
// One row per party member. The JS draws a blob thumbnail (spr), name/Lv, an HP bar (cur/max), and the type name.
let mut i = 0
while i < 파티.length {
let 원 = 파티[i]
let 종정보 = 종찾기(원.종아이디)
let 선택 = if i == 에스.파티커서 { 1 } else { 0 }
let 초점표 = if i == 에스.초점 { 1 } else { 0 } // 초점(키우는 중) 마커: 이 한 마리가 배틀·진화한다. / the focused (being-raised) creature.
let 속성라벨 = 속성이름(원.속성)
줄들 = "{줄들}\nPROW|{i}|{선택}|{원.스프라이트}|{종정보.이름}|{원.레벨}|{원.현재체력}|{원.최대체력}|{속성라벨}|{초점표}"
// P4: 각 파티원 뒤에 DRIFT 줄(드리프트 미터 + 게놈/표현형). JS가 행마다 드리프트 막대와 블롭 외형 너지를 그린다.
// P4: a DRIFT line after each member (meter + genome/phenotype); the JS draws a per-row drift bar and the blob appearance nudge.
줄들 = "{줄들}\n{드리프트줄("party:{i}", 원)}"
i = i + 1
}
// 커서 + 닫기 안내(B 또는 START). / Cursor + a close hint (B or START).
줄들 = "{줄들}\nPCURSOR|{에스.파티커서}\nTXT|8|132|144|A 키우기(초점) B/START 닫기"
줄들
}
// ── 진화 장면 드로우리스트(P5, 분기 LOCK의 가시화) ───────────────────────────────────────────────────
// choose 단계: 제목 + 부모 형태 + 후보 분기 목록(각 줄: 분기 K · 파생 형태이름 · 돌연변이 게놈요약 · +스탯부스트)을 EMENU/ECAND 디렉티브로 내린다.
// 같은 시드+같은 부모 → 같은 후보들(재유도). 플레이어가 ←→로 커서, A로 한 분기를 LOCK(입력 시퀀스라 재현). JS가 후보 블롭을 게놈으로 그린다.
// morph 단계: EVOLVE 디렉티브 한 줄(from-form·to-form·from-genome·to-genome·branch) + 확정 텍스트("X 형태로 진화했습니다 (세대 N)").
// JS가 EVOLVE 디렉티브로 느린 도트-모핑 컷신을 재생한다(빛 블룸 + 블롭이 옛 게놈 외형 → 새 게놈 외형으로). 토파즈는 결과만 내고 애니는 JS.
// Evolution-scene draw list (P5, the visible branch LOCK). choose: title + parent form + the candidate branches (EMENU/ECAND: branch K,
// derived form-name, mutated genome summary, +stat boost) — same seed+parent re-derives the same candidates; arrows move the cursor, A LOCKS
// (part of the input sequence -> reproducible). morph: one EVOLVE directive (from/to form + from/to genome + branch) + a confirmation line;
// the JS plays the slow dot-morph cutscene from that directive (Topaz only emits the result; the animation is JS).
function 진화드로우(에스: state.상태, 팔레트: string) -> string {
let 진 = 에스.진화
let 활성 = 초점전투원(에스) // 진화 중인 전투원 = 초점(choose=부모, morph=잠긴 자식) / the evolving battler = the focused creature
let mut 줄들 = "DL1\nPAL|0|{팔레트}\nBATTLEBG|0"
match 진.단계 {
case "morph" => {
// ── morph: EVOLVE 모핑 디렉티브 + 확정 텍스트. JS가 이 한 프레임에 모핑 컷신을 재생한다. ──
// EVOLVE|<fromForm>|<toForm>|<fromGenomeCsv>|<toGenomeCsv>|<branch>|<spr> — 모핑 descriptor. spr=종 스프라이트(블롭 색).
// EVOLVE morph descriptor: from/to form names, from/to genome (csv), the branch index, and the species sprite (blob color set).
let evolve줄 = "EVOLVE|{진.부모형태}|{진.결과형태}|{게놈요약(진.부모게놈)}|{게놈요약(진.결과게놈)}|{진.잠긴분기}|{활성.스프라이트}"
// 잠긴 형태를 큰 블롭으로 중앙에 (JS가 모핑의 최종 프레임으로 그린다). / the locked form as a big centered blob (JS draws it as the morph's final frame).
let 블롭줄 = "CSPR|{활성.스프라이트}|72|48|0|5|{게놈요약(활성.게놈)}" // 진화 결과 블롭도 게놈으로. / morph result blob, genome-driven.
let 박스줄 = "BOX|0|104|160|40|0"
let 확정줄 = "TXT|8|112|144|{진.결과형태}(으)로 진화했습니다"
let 세대줄 = "TXT|8|124|144|세대 {활성.세대} 게놈 {게놈요약(활성.게놈)}"
줄들 = "{줄들}\n{evolve줄}\n{블롭줄}\n{박스줄}\n{확정줄}\n{세대줄}"
return 줄들
}
case "choose" => ()
case "none" => ()
}
// ── choose: 후보 분기 메뉴. 부모를 재유도 엔티티로 후보들을 만들고(메뉴=고정과 같은 함수), 각 후보를 ECAND 줄로 내린다. ──
let 엔티티 = evolution.계통유아이디(진.부모계통시드)
let 후보들 = evolution.후보들유도(진.부모게놈, 엔티티, 진.부모계통시드, 진.부모진화카운터)
let 제목줄 = "BOX|0|0|160|30|0"
let 제목텍스트 = "TXT|8|6|150|진화의 갈림길"
let 부모줄 = "TXT|8|18|150|{진.부모형태} 게놈 {게놈요약(진.부모게놈)}"
// EPARENT: 부모 게놈(후보 블롭 너지의 기준선). JS가 후보 게놈을 이 기준 대비 너지해 분기별로 다른 블롭을 그린다. / parent genome baseline for the candidate blobs.
let 부모게놈줄 = "EPARENT|{게놈요약(진.부모게놈)}"
줄들 = "{줄들}\n{제목줄}\n{제목텍스트}\n{부모줄}\n{부모게놈줄}"
// 각 후보를 ECAND 줄로. ECAND|<branch>|<selected>|<spr>|<maskedName>|<genomeCsv>|<boost>|<hint>. 이름은 가리고(확정 전), 부분 힌트만 준다 — "느림 = 관찰".
// One ECAND line per candidate. The NAME is masked until locked; only a partial biological hint is shown (slow = observation). JS draws the blob from its genome.
let mut i = 0
while i < 후보들.length {
let 후 = 후보들[i]
let 선택 = if i == 진.선택커서 { 1 } else { 0 }
줄들 = "{줄들}\nECAND|{후.분기}|{선택}|{활성.스프라이트}|{후.형태이름}|{게놈요약(후.게놈)}|{후.스탯부스트}|{evolution.후보힌트(후)}"
i = i + 1
}
줄들 = "{줄들}\nEMENU|{진.선택커서}|{후보들.length}"
줄들 = "{줄들}\nTXT|8|132|150|←→ 선택 A 진화 확정"
줄들
}
// 분기 인덱스를 글자로(0→A·1→B·2→C). / branch index to a letter.
function 분기문자(인덱스: int) -> string {
if 인덱스 == 0 { "A" } else if 인덱스 == 1 { "B" } else if 인덱스 == 2 { "C" } else { "?" }
}
// 가장 깊은 계통의 분기 선택 경로를 "A-C-B" 코드로 만든다(뿌리→깊은). 가장 깊은 노드에서 부모유아이디를 따라 올라가며 각 진화의 분기(노드분기)를 글자로.
// 같은 시드 + 같은 선택 → 같은 코드. "시드 X · 계통 A-C-B" 로 비교·공유 가능한 진화 이력. 뿌리만(진화 0)이면 "".
// The branch-choice path of the deepest lineage as an "A-C-B" code (root->deep). Same seed + same choices -> the same code (a comparable evolutionary history).
function 계통경로코드(노드들: Array<evolution.계통노드>) -> string {
if 노드들.length == 0 { return "" }
let mut 깊은 = 노드들[0]
for nd in 노드들 { if nd.세대 > 깊은.세대 { 깊은 = nd } }
if 깊은.세대 <= 0 { return "" }
let mut 현재 = 깊은
let mut 코드 = ""
let mut 계속 = true
let mut 가드 = 0
while 계속 && 현재.세대 > 0 && 가드 < 100 {
let mut 부모찾음 = false
let mut 부모 = 현재
for nd in 노드들 { if nd.유아이디 == 현재.부모유아이디 { 부모 = nd ; 부모찾음 = true } }
if 부모찾음 {
let 분기 = evolution.노드분기(게놈요약파싱(부모.게놈요약), 부모.시드지문, 부모.세대, 현재.게놈요약)
let 글 = 분기문자(분기)
코드 = if 코드 == "" { 글 } else { "{글}-{코드}" }
현재 = 부모
가드 = 가드 + 1
} else {
계속 = false
}
}
코드
}
// 한 뿌리(세대 0)에서 아래로 외길 사슬을 따라가며 각 진화의 분기를 모은다 → "A-C-B". 자식 없으면(미진화) ".". 분기 매칭 실패는 정직하게 "?"(추측 금지).
// Walk DOWN one root's single-child chain, collecting branch letters -> "A-C-B"; "." if unevolved; "?" on an ambiguous/failed match (never guessed).
function 뿌리경로(노드들: Array<evolution.계통노드>, 뿌리: evolution.계통노드) -> string {
let mut 현재 = 뿌리
let mut 코드 = ""
let mut 계속 = true
let mut 가드 = 0
while 계속 && 가드 < 100 {
let mut 자식찾음 = false
let mut 자식 = 현재
for nd in 노드들 {
if !자식찾음 && nd.부모유아이디 == 현재.유아이디 && nd.유아이디 != 현재.유아이디 { 자식 = nd ; 자식찾음 = true }
}
if 자식찾음 {
let 분기 = evolution.노드분기(게놈요약파싱(현재.게놈요약), 현재.시드지문, 현재.세대, 자식.게놈요약)
let 글 = if 분기 >= 0 { 분기문자(분기) } else { "?" }
코드 = if 코드 == "" { 글 } else { "{코드}-{글}" }
현재 = 자식
가드 = 가드 + 1
} else {
계속 = false
}
}
if 코드 == "" { "." } else { 코드 }
}
// 숲 공유 코드 디렉티브 블록: FHEAD|F1|<시드>|<정복 0/1> + 뿌리(세대 0)마다 FROW|<출처키>|<경로>. 발견 순서(시조 먼저, 포획 순) — 같은 시드+같은 선택이면 같은 코드(비교·공유 가능).
// Forest-code directive block: a header + one row per root (gen-0 node) in discovery order. Deterministic: same seed + same choices -> the same code.
function 숲줄들(에스: state.상태) -> string {
let 노드들 = 에스.계통노드들
let 정복 = if 처치포함(에스.처치된것들, 14) { 1 } else { 0 }
let mut 블록 = "\nFHEAD|F1|{에스.월드시드}|{정복}"
for nd in 노드들 {
if nd.세대 == 0 {
블록 = "{블록}\nFROW|{출처키(nd.시드지문)}|{뿌리경로(노드들, nd)}"
}
}
블록
}
// ── Lineage Dex(계통수) 드로우리스트(P6, 프로젝트의 센터피스) ────────────────────────────────────────
// 계통 노드 리스트로 트리 레이아웃을 계산하고(core.lineagedex, 순수·결정론·유계), 노드/엣지/제목/커서/상세를 디렉티브로 내린다.
// DEXVIEW|<treeW>|<treeH>|<count> 트리 전체 픽셀 크기(JS 스크롤) + 노드 수
// DEXEDGE|<x1>|<y1>|<x2>|<y2> 부모 노드 → 자식 노드 연결선(부모가 있을 때만). 노드보다 먼저 내려 선이 아래에 깔리게.
// DEXNODE|<idx>|<parentIdx>|<x>|<y>|<genomeCsv>|<formName>|<generation>|<selected> 노드 한 개(블롭 + 라벨 + 세대 마커).
// DEXCURSOR|<idx> 선택된 노드 인덱스(발견 순서).
// DEXDETAIL|<formName>|<generation>|<genomeCsv>|<seedFingerprint> 선택 노드 상세(이름·세대·게놈·시드 지문 — 공유 가능).
// 같은 노드 리스트 → 같은 디렉티브(바이트 동일·재현). 시드 지문이 "이 시드가 이 나무를 키웠다"를 공유 가능하게 한다.
// Lineage-Dex draw list (P6 centerpiece): compute the tree layout (pure/deterministic/bounded), then emit edges (under nodes), nodes
// (blob + label + generation marker), the cursor, and the selected-node detail (name/gen/genome/SEED FINGERPRINT — the shareable bit).
function 도감드로우(에스: state.상태, 팔레트: string) -> string {
let 노드들 = 에스.계통노드들
let 레이아웃 = lineagedex.레이아웃계산(노드들)
let 배치들 = 레이아웃.노드들
// 커서를 배치된 노드 범위로 가둔다(빈 트리는 0에 고정). 커서가 가리키는 "배치 위치"를 찾아 상세를 그린다. / clamp cursor into the placed-node range.
let 커서 = if 배치들.length > 0 { 범위가두기(에스.도감커서, 0, 배치들.length - 1) } else { 0 }
// 제목 박스 + 제목 + 진척 요약. 가장 깊은 세대 + 발견 형태 수 + 월드시드(이 나무를 키운 공유 코드)를 보여줘 "나무가 자란다"는 목표가 느껴지게 한다.
// Title box + title + a progress summary: deepest generation + forms discovered + the world seed (the share code that grew THIS tree).
let 제목줄 = "BOX|0|0|160|18|0"
let 제목텍스트 = "TXT|6|5|90|계통도감"
let mut 최고세대 = 0
for 노 in 노드들 {
if 노.세대 > 최고세대 { 최고세대 = 노.세대 }
}
// DEXSTAT|maxGen|formCount|worldSeed — 헤더 우측에 "G{세대} · {수}형태 · {시드}" 로 그린다. 같은 시드는 같은 나무(공유·재현). / progress + share code.
let 통계줄 = "DEXSTAT|{최고세대}|{노드들.length}|{에스.월드시드}"
// DEXPATH|code|seed — 가장 깊은 계통의 분기 선택 경로(A-C-B). "시드 X · 계통 A-C-B" = 비교·공유 가능한 진화 이력. 진화 0이면 생략. / lineage path code.
let 경로코드 = 계통경로코드(노드들)
let 경로줄 = if 경로코드 != "" { "\nDEXPATH|{경로코드}|{에스.월드시드}" } else { "" }
// DEXWIN: 심연군주(엔티티 14)를 처치했으면 "심층 정복" 승리 배너를 올린다. 엔드게임의 보상 — 계통을 끝까지 키운 증표. / endgame victory banner when the boss is cleared.
let 승리줄 = if 처치포함(에스.처치된것들, 14) { "\nDEXWIN|1" } else { "" }
let 숲줄 = 숲줄들(에스) // 숲 공유 코드(FHEAD + 뿌리별 FROW). JS가 도감에서 'A'로 토글해 보여주고 복사한다. / forest share code; JS toggles it in the dex.
// DEXVIEW: 트리 전체 크기(스크롤) + 노드 수. JS가 이걸로 스크롤 범위를 잡는다. / DEXVIEW: full extent for scrolling + node count.
let 뷰줄 = "DEXVIEW|{레이아웃.너비}|{레이아웃.높이}|{배치들.length}"
// 엣지 먼저(선이 노드 아래로 깔리게). 각 배치 노드의 부모배치가 있으면 부모 중심 → 자식 중심 선분. / edges first (drawn under nodes).
let mut 엣지부 = ""
let mut e = 0
while e < 배치들.length {
let 노 = 배치들[e]
if 노.부모인덱스 >= 0 && 노.부모인덱스 < 배치들.length {
let 부 = 배치들[노.부모인덱스]
// 부모 카드 중심(우측) → 자식 카드 중심(좌측) 근사 좌표. JS가 같은 카드 오프셋으로 곡선/직선을 그린다. / parent->child segment.
엣지부 = "{엣지부}\nDEXEDGE|{부.엑스}|{부.와이}|{노.엑스}|{노.와이}"
}
e = e + 1
}
// 노드들. DEXNODE|idx|parentIdx|x|y|genomeCsv|formName|generation|selected. 인덱스 = 노드 리스트의 발견 순서(커서 값과 일치).
// selected = 이 노드의 노드-리스트 인덱스가 커서가 가리키는 배치노드의 인덱스와 같은가. / nodes; selected matches the cursor's node-list index.
let 선택인덱스 = if 배치들.length > 0 { 배치들[커서].인덱스 } else { -1 }
let mut 노드부 = ""
let mut n = 0
while n < 배치들.length {
let 노 = 배치들[n]
let 선택 = if 노.인덱스 == 선택인덱스 { 1 } else { 0 }
노드부 = "{노드부}\nDEXNODE|{노.인덱스}|{노.부모인덱스}|{노.엑스}|{노.와이}|{노.게놈요약}|{노.형태이름}|{노.세대}|{선택}"
n = n + 1
}
// 커서 줄 + 선택 노드 상세(이름·세대·게놈·시드 지문). 시드 지문 = 공유의 핵심("이 시드가 이 나무를 키웠다"). 빈 트리면 상세 생략.
// Cursor line + the selected-node detail (name/gen/genome/SEED FINGERPRINT — the share key). Skipped on an empty tree.
let 커서줄 = "DEXCURSOR|{커서}"
let 상세줄 = if 배치들.length > 0 {
let 선 = 배치들[커서]
"DEXDETAIL|{선.형태이름}|{선.세대}|{선.게놈요약}|{선.시드지문}|{출처(선.시드지문)}"
} else {
"TXT|8|72|144|아직 계통이 없습니다"
}
// "가지 않은 길": 선택 노드가 세대>0 이면, 그 진화 갈림길에서 고르지 않은 분기들의 힌트를 재유도해 내린다(부모 노드에서 후보 재유도, 결정론).
// 같은 시드면 늘 같은 형제 → "다른 선택이었다면 다른 나무"를 보여 준다. 부모를 못 찾거나 형제가 없으면 빈 줄. / roads not taken for the selected node.
let mut 형제줄 = ""
if 배치들.length > 0 && 선택인덱스 >= 0 && 선택인덱스 < 노드들.length {
let 선노드 = 노드들[선택인덱스]
if 선노드.세대 > 0 {
let mut 부모찾음 = false
let mut 부모노드 = 선노드
for nd in 노드들 {
if nd.유아이디 == 선노드.부모유아이디 { 부모노드 = nd ; 부모찾음 = true }
}
if 부모찾음 {
let 부모게놈 = 게놈요약파싱(부모노드.게놈요약)
let 힌트들 = evolution.형제힌트들(부모게놈, 부모노드.시드지문, 부모노드.세대, 선노드.게놈요약)
if 힌트들 != "" {
형제줄 = "\nDEXSIBLING|{힌트들}"
}
}
}
}
// 닫기 안내(B/SELECT) + 커서 이동 안내. / close hint (B/SELECT) + a cursor hint.
let 안내줄 = "TXT|6|136|150|←→↑↓ 선택 B/SEL 닫기"
"DL1\nPAL|0|{팔레트}\n{제목줄}\n{제목텍스트}\n{통계줄}{경로줄}{승리줄}{숲줄}\n{뷰줄}{엣지부}{노드부}\n{커서줄}\n{상세줄}{형제줄}\n{안내줄}"
}
// 상태 → DRAW 블록 본문. 장면으로 분기한다(field/battle/party/evolve/dex). 셸은 이 본문을 main의 DRAW: 아래로 받는다.
// State -> the DRAW block body. Branch on the scene (field/battle/party/evolve/dex). The shell receives it under main's DRAW: line.
export function 드로우리스트(에스: state.상태) -> string {
let 팔레트 = 연못팔레트()
match 에스.장면 {
case "battle" => 배틀드로우(에스, 팔레트)
case "party" => 파티드로우(에스, 팔레트)
case "evolve" => 진화드로우(에스, 팔레트)
case "dex" => 도감드로우(에스, 팔레트)
case "story" => 스토리드로우(에스, 팔레트)
case "field" => 필드드로우(에스, 팔레트)
}
}
// 스토리 카드: 필드를 배경으로 깔고 그 위에 내레이션 카드를 얹는다. 현재 비트 = 대기 최하위. 내레이션 줄바꿈(\n)은 |로 나눠 보내 디렉티브 한 줄로 유지(드로우는 줄 단위 파싱). JS가 풀스크린 카드로 그린다.
// Story card: the field as a backdrop + a narration card. Current beat = lowest queue bit. Newlines are split to "|" fields so the directive stays single-line. The JS draws a full-screen card.
function 스토리드로우(에스: state.상태, 팔레트: string) -> string {
let 비트 = 최하위비트(에스.스토리.대기)
let 텍스트 = 비트내레이션(비트)
let 줄들 = 텍스트.split("\n")
let mut 줄부 = ""
for l in 줄들 { 줄부 = "{줄부}|{l}" }
"{필드드로우(에스, 팔레트)}\nSTORY|{비트}{줄부}"
}
// ===== core/util.tpz =====
// 공통 순수 유틸리티. 작은 산술 가두기와 배열 fresh-copy 헬퍼만 둔다.
// Shared pure utilities: bounded integer helpers and fresh-copy array helpers.
//
// 하우스룰(core): export는 타입/함수/불변 최상위만. 최상위 가변·print·input·host 금지.
// House rule (core): export only types/functions/immutable bindings; no module-level mutable/print/input/host.
// 값을 [하한, 상한] 범위로 가둔다. 하한 검사를 먼저 해 기존 if-chain clamp의 순서를 보존한다.
// Clamp into [lo, hi]. The lower-bound check runs first to preserve the old if-chain ordering.
export function 범위가두기(값: int, 하한: int, 상한: int) -> int {
if 값 < 하한 { return 하한 }
if 값 > 상한 { return 상한 }
값
}
// 하한만 가둔다. 음수 방어·최소 피해/확률 같은 단일 경계에 쓴다. / Clamp only the lower bound.
export function 하한가두기(값: int, 하한: int) -> int {
if 값 < 하한 { 하한 } else { 값 }
}
// 상한만 가둔다. HP/미터처럼 위쪽만 막는 단일 경계에 쓴다. / Clamp only the upper bound.
export function 상한가두기(값: int, 상한: int) -> int {
if 값 > 상한 { 상한 } else { 값 }
}
// 배열을 새 배열로 복사한다. 토파즈 배열은 참조형이라 core 경계에서 입력 배열을 직접 push/수정하지 않는다.
// Copy into a fresh array. Topaz arrays are reference-like, so core code does not push/mutate caller-owned arrays.
export function 복사<T>(원본: Array<T>) -> Array<T> {
let mut 결과: Array<T> = []
for 항목 in 원본 { 결과.push(항목) }
결과
}
// 배열을 새 배열로 복사하고 끝에 하나를 붙인다. / Copy into a fresh array and append one item.
export function 복사추가<T>(원본: Array<T>, 추가: T) -> Array<T> {
let mut 결과 = 복사(원본)
결과.push(추가)
결과
}
// ===== core/rng.tpz =====
// 결정론 산술 PRNG — 결정론의 등뼈(core의 잎 모듈, 어떤 것도 import 안 함, data도 안 함).
// 비트연산 없음(토파즈 `>>`는 합성·비트시프트 아님). 곱셈·덧셈·나머지·나눗셈만으로 만든 LCG/곱셈해시 PRNG.
// Deterministic arithmetic PRNG — the backbone of determinism (a leaf module of core: imports nothing, not even data).
// NO bitwise ops (Topaz `>>` is composition, not a bit-shift). An LCG / multiplicative-hash PRNG from only *, +, %, /.
//
// 하우스룰(core): export는 타입/함수/불변 최상위만. 최상위 가변·print·input·host(now_millis·파일IO·미시드난수) 금지.
// House rule (core): export only types/functions/immutable bindings; no module-level mutable state/print/input/host
// (no now_millis, no file IO, no unseeded randomness). 지역 let mut만 허용 / local let mut only.
//
// ── 체크드 정수 한계 (필수) ──────────────────────────────────────────────────────────────────
// 토파즈 정수 산술은 체크드다: 곱셈이 64비트 부호있는 한계(≈9.22e18)를 넘으면 TPZ4004로 멈춘다.
// 그래서 PRNG는 전부 모듈러스 m=2^31 안에서 일한다. 상태 x는 항상 [0, 2^31). 곱수 a는 a*(2^31-1)+c < 9.22e18 이도록 고른다:
// 곱수 1664525 * (2^31-1) ≈ 3.57e15, 곱수2 1013904223 * (2^31-1) ≈ 2.18e18 — 둘 다 안전.
// 문자열해시의 중간값 h*31 < 6.6e10 도 안전. 이렇게 모든 곱·합 중간값이 9.22e18 아래로 유계라 절대 오버플로 안 한다.
// Topaz int arithmetic is CHECKED: a multiply past the 64-bit signed bound (≈9.22e18) stops with TPZ4004.
// So the whole PRNG works inside modulus m = 2^31. State x stays in [0, 2^31). Multipliers a are chosen so
// a*(2^31-1)+c < 9.22e18: 1664525*(2^31-1) ≈ 3.57e15, 1013904223*(2^31-1) ≈ 2.18e18 — both safe;
// the string-hash intermediate h*31 < 6.6e10 is safe too. Every product/sum stays under the bound -> never overflows.
//
// ── 도메인 분리 카운터 구조 ──────────────────────────────────────────────────────────────────
// 난수(도메인, 엔티티, 카운터, 시드): 같은 입력 → 같은 출력. 서로 다른 도메인/카운터 → 독립적이고 잘 퍼진 스트림.
// 배틀 난수가 조우·돌연변이 난수를 흔들지 못한다(도메인 문자열로 스트림을 분리). 카운터 기반이라 전역 가변 상태가 없다 →
// 리플레이·디버그·시드 공유가 자동이다. 시드 STRING에서 안정 해시로 정수를 얻어 도메인/엔티티/카운터와 섞는다.
// 난수(domain, entity, counter, seed): same inputs -> same output; different domains/counters -> well-spread,
// independent streams (battle rolls cannot perturb encounter/mutation rolls — the domain string separates streams).
// Counter-based => no global mutable state => replay/debug/seed-sharing for free. A stable hash turns the seed STRING
// into an int, then domain/entity/counter are folded in.
//
// Key identifiers: 모듈러스=modulus, 곱수=multiplier, 증분=increment, 문자열해시=hashString, 엘시지=lcgStep,
// 정규=normalize(0..m), 섞기=mix, 난수=rand, 난수범위=randRange, 굴림99=roll0to99.
// 2^31. 모든 상태/출력이 [0, 모듈러스) 안에 머문다. / All state/output stays in [0, modulus). 2^31 = 2147483648.
let 모듈러스 = 2147483648
// LCG 상수: 곱수*(2^31-1)+증분 ≈ 3.57e15 < 9.22e18 (안전). 증분은 홀수(전 주기 보장 조건).
// LCG constants: multiplier*(2^31-1)+increment ≈ 3.57e15 < 9.22e18 (safe). Increment is odd (full-period condition).
let 곱수 = 1664525
let 증분 = 1013904223
// 두 번째 홀수 곱수(섞기에서 입력을 비선형으로 접는다). 1013904223*(2^31-1) ≈ 2.18e18 < 9.22e18 (안전).
// Second odd multiplier (folds inputs nonlinearly in 섞기). 1013904223*(2^31-1) ≈ 2.18e18 < 9.22e18 (safe).
let 곱수2 = 1013904223
// 안정 문자열 해시: 시드 STRING → [0, 2^31) 정수. 같은 문자열은 항상 같은 값(폴리(31) 해시, 매 단계 모듈러스로 유계).
// 중간값 h*31 < (2^31)*31 ≈ 6.66e10 < 9.22e18 이라 오버플로 없다. codePointAt 으로 글자마다 코드포인트를 섞는다.
// Stable string hash: seed STRING -> int in [0, 2^31). Same string -> same value (poly(31) hash, bounded mod modulus
// each step). The intermediate h*31 < (2^31)*31 ≈ 6.66e10 < 9.22e18, so no overflow. Folds each codepoint via codePointAt.
export function 문자열해시(문자열: string) -> int {
// FNV 오프셋 베이시스를 모듈러스로 줄여 시작값으로 쓴다(비영·잘 흩어진 시드). / FNV offset basis reduced mod m as a nonzero seed.
let mut 해시 = 2166136261 % 모듈러스
let 길이 = 문자열.scalars().length
let mut 인덱스 = 0
while 인덱스 < 길이 {
let 코드 = 문자열.codePointAt(인덱스) ?? 0
해시 = (해시 * 31 + 코드) % 모듈러스
인덱스 = 인덱스 + 1
}
해시
}
// LCG 한 스텝: x' = (곱수*x + 증분) mod 모듈러스. x<2^31 이므로 곱수*x < 3.57e15 (안전).
// One LCG step: x' = (multiplier*x + increment) mod modulus. With x<2^31, multiplier*x < 3.57e15 (safe).
function 엘시지(엑스: int) -> int {
(곱수 * 엑스 + 증분) % 모듈러스
}
// 값을 [0, 모듈러스) 로 정규화한다(음수 카운터·엔티티 방어; 토파즈 %는 음수에 음수를 돌려주므로).
// Normalize a value into [0, modulus) (guards negative counter/entity — Topaz % returns negative for negatives).
function 정규(값: int) -> int {
((값 % 모듈러스) + 모듈러스) % 모듈러스
}
// 입력 값을 상태에 비선형으로 접어 넣는다: 더하고 곱수2로 곱한 뒤 LCG 한 스텝. 인접 카운터가 선형으로 안 변하게 한다.
// 곱수2*((z+v) mod m) < 2.18e18 (안전). / Fold a value into state nonlinearly: add, multiply by 곱수2, then one LCG step.
// This breaks the linearity so adjacent counters diverge. 곱수2*((z+v) mod m) < 2.18e18 (safe).
function 섞기(상태: int, 값: int) -> int {
엘시지((곱수2 * ((상태 + 정규(값)) % 모듈러스)) % 모듈러스)
}
// 도메인 분리 카운터 난수: (도메인, 엔티티, 카운터, 시드) → [0, 2^31) 정수. 같은 입력 = 같은 값.
// 시드해시에서 출발해 도메인·엔티티·카운터·도메인을 차례로 접고, 마지막에 상·하위 절반을 비선형으로 섞어
// 결과가 카운터의 저차 선형 함수가 되지 않게 한다(작은 폭으로 줄여도 잘 퍼지게). 어떤 단계도 모듈러스를 안 넘는다.
// Domain-separated counter RNG: (domain, entity, counter, seed) -> int in [0, 2^31). Same inputs = same value.
// Start from the seed hash, fold domain/entity/counter/domain, then a final avalanche mixes the high and low halves
// so the result is NOT a low-order linear function of the counter (stays well-spread even reduced to a small range).
// No stage exceeds the modulus.
export function 난수(도메인: string, 엔티티: int, 카운터: int, 시드: string) -> int {
let 시드값 = 문자열해시(시드)
let 도메인값 = 문자열해시(도메인)
let mut 상태 = 시드값
상태 = 섞기(상태, 도메인값)
상태 = 섞기(상태, 엔티티)
상태 = 섞기(상태, 카운터)
상태 = 섞기(상태, 도메인값) // 카운터 뒤에 도메인을 한 번 더 섞어 도메인 영향을 유지한다 / re-mix domain after counter
상태 = 엘시지(상태)
// 최종 애벌랜치: 상위 15비트와 하위 16비트를 비선형으로 결합(저차 선형성 제거). 상·하위 모두 < 2^16.
// Final avalanche: combine the high 15 bits and low 16 bits nonlinearly (removes low-order linearity). Both < 2^16.
let 상위 = 상태 / 65536
let 하위 = 상태 % 65536
엘시지((곱수2 * ((상위 + 하위 * 3) % 모듈러스)) % 모듈러스)
}
// 난수를 [하한, 상한] 닫힌 구간으로 줄인다. LCG의 저차 비트는 규칙적이므로 상위 비트(/ 21474836)에서 폭으로 줄인다.
// 21474836 = 2^31 / 100 근사 → 0..99 류에서 균일에 가깝다. / Reduce into the closed range [하한, 상한].
// LCG low bits are regular, so reduce from the HIGH bits (/ 21474836 ≈ 2^31/100) -> near-uniform for 0..99-style ranges.
export function 난수범위(도메인: string, 엔티티: int, 카운터: int, 시드: string, 하한: int, 상한: int) -> int {
let 폭 = 상한 - 하한 + 1
let 값 = 난수(도메인, 엔티티, 카운터, 시드)
하한 + ((값 / 21474836) % 폭)
}
// 흔한 0..99 굴림(조우율·확률 게이트용 단축). / The common 0..99 roll (shorthand for encounter rate / probability gates).
export function 굴림99(도메인: string, 엔티티: int, 카운터: int, 시드: string) -> int {
난수범위(도메인, 엔티티, 카운터, 시드, 0, 99)
}
// ===== core/encounter.tpz =====
// 조우 로직(순수): 키큰풀 한 칸을 밟으면 PRNG의 조우 도메인을 굴려 가끔(튜닝 가능한 율) 조우가 일어나고,
// 조우테이블에서 야생 종을 뽑아 최소 야생 크리처 인스턴스(종·레벨·체력)를 생성한다.
// Encounter logic (pure): stepping onto a tall-grass tile rolls the PRNG's encounter domain — sometimes (a tunable rate)
// an encounter occurs; pick a wild species from the table and build a minimal wild-creature instance (species, level, HP).
//
// 결정론(핵심 주장): 같은 월드시드 + 같은 이동 경로 → 같은 스텝에서 같은 조우. 카운터 = 스텝·위치에서 파생되어 재현 가능.
// Determinism (the core claim): same world_seed + same movement path -> the SAME encounters at the SAME steps,
// because the counter is derived from the step/position (reproducible).
//
// 하우스룰(core): export는 타입/함수/불변 최상위만. 최상위 가변·print·input·host 금지. deps는 안으로만(core->data).
// House rule (core): export only types/functions/immutable bindings; no module-level mutable/print/input/host. core->data only.
//
// Key identifiers: 야생크리처=WildCreature, 조우결과=EncounterResult, 조우카운터=encounterCounter,
// 조우판정=rollEncounter, 야생생성=spawnWild.
import core.rng { 굴림99, 난수범위 }
import data.encounters { 조우율, 가중합, 종뽑기 }
import data.species { 종찾기 }
// 최소 야생 크리처 인스턴스. P1은 종·레벨·체력만(게놈·진화는 P4+). / Minimal wild-creature instance: species/level/HP only.
export type 야생크리처 = {
종아이디: int,
이름: string,
레벨: int,
체력: int,
스프라이트: int,
속성: int
}
// 조우 판정 결과. 조우안함이면 크리처는 더미(종아이디 0). / Encounter outcome; on no-encounter the creature is a dummy (id 0).
export type 조우결과 = {
조우함: bool,
크리처: 야생크리처
}
// 조우 카운터: 스텝과 위치에서 안정적으로 파생한다. 같은 시드+경로 → 매 스텝 같은 카운터 → 같은 조우.
// 위치를 섞어 같은 스텝이라도 다른 칸이면 다른 굴림이 되게 한다(맵너비 24 가정, x+y*24가 칸 고유값).
// Encounter counter: derived stably from step and position. Same seed+path -> same counter each step -> same encounters.
// Position is folded in so the same step on a different tile rolls differently (cell-unique x + y*24, map width 24).
export function 조우카운터(스텝: int, 엑스: int, 와이: int) -> int {
// 스텝을 200만으로 줄인 뒤 띄우고 칸 고유값을 더한다: 카운터 충돌 방지 + PRNG 정규화 전에 checked-int가 넘치지 않게.
// Reduce the step mod 2,000,000 before spreading, then add the cell-unique value: avoids counter collisions AND
// keeps the product inside Topaz's CHECKED int (must not overflow before the PRNG normalizes it). 200만 스텝은 사실상 도달 불가.
(스텝 % 2000000) * 1000 + (엑스 + 와이 * 24)
}
// 야생 크리처를 생성한다(종 뽑기 + 레벨 굴림 + 종 기본체력). 별도 도메인/카운터 오프셋으로 종·레벨 스트림을 분리한다.
// Spawn a wild creature: pick the species, roll the level in its band, take the species base HP. Separate domain/counter
// offsets keep the species pick and the level roll independent. / 도메인 분리로 배틀·돌연변이 난수와 섞이지 않는다.
export function 야생생성(스텝: int, 엑스: int, 와이: int, 시드: string) -> 야생크리처 {
let 기본카운터 = 조우카운터(스텝, 엑스, 와이)
// 종 뽑기: 0..(가중합-1) 굴림 → 누적 가중치로 종 선택. / Pick species: a 0..(totalWeight-1) roll -> cumulative weight.
let 종굴림 = 난수범위("encounter_species", 0, 기본카운터, 시드, 0, 가중합() - 1)
let 종아이디 = 종뽑기(종굴림)
let 종정보 = 종찾기(종아이디)
// 레벨 굴림: 종의 [최소,최대] 레벨 안에서. 다른 도메인이라 종 굴림과 독립. / Level roll within the species band, distinct domain.
let 레벨 = 난수범위("encounter_level", 종아이디, 기본카운터, 시드, 종정보.최소레벨, 종정보.최대레벨)
{
종아이디: 종정보.아이디,
이름: 종정보.이름,
레벨: 레벨,
체력: 종정보.기본체력,
스프라이트: 종정보.스프라이트,
속성: 종정보.속성
}
}
// 조우 판정: 키큰풀(타일 1)에 들어왔을 때만 호출한다. 조우율 게이트를 통과하면 야생을 생성한다.
// Roll an encounter: call only when the player steps onto a tall-grass tile (id 1). If the rate gate passes, spawn a wild.
// 굴림 < 조우율 → 조우. 카운터는 스텝/위치라서 같은 경로면 같은 결과. / roll < rate => encounter; reproducible per path.
export function 조우판정(스텝: int, 엑스: int, 와이: int, 시드: string) -> 조우결과 {
let 게이트 = 굴림99("encounter", 0, 조우카운터(스텝, 엑스, 와이), 시드)
if 게이트 < 조우율() {
{ 조우함: true, 크리처: 야생생성(스텝, 엑스, 와이, 시드) }
} else {
// 조우 안 함: 더미 크리처(종아이디 0). 셸/직렬화는 조우함=false면 크리처를 무시한다.
// No encounter: a dummy creature (id 0). The shell/serialization ignores the creature when 조우함=false.
{
조우함: false,
크리처: { 종아이디: 0, 이름: "", 레벨: 0, 체력: 0, 스프라이트: 0, 속성: 0 }
}
}
}
// ===== core/creature.tpz =====
// 전투원(combatant) 레코드 + 스탯 유도 + 레벨/EXP 곡선 — 코어의 잎(rng·data 만 의존). 순수.
// 종 기준치에서 레벨로 스탯을 유도하고, 종을 받아 전투원을 만든다. 파티(P2는 1마리)·야생 둘 다 이 타입을 쓴다.
// Combatant record + stat derivation + level/EXP curve — a core leaf (depends only on rng/data). Pure.
// Derives stats from a species' base values by level, and builds a combatant from a species. Both the party
// (one creature in P2) and the wild use this type.
//
// 하우스룰(core): export는 타입/함수/불변 최상위만. 최상위 가변·print·input·host(now_millis·파일IO·미시드난수) 금지.
// House rule (core): export only types/functions/immutable bindings; no module-level mutable/print/input/host. core->data only.
//
// ── 스탯 유도(부동소수 없음) / stat derivation (no floats) ──────────────────────────────────────────
// 최대체력 = 기본체력 + 레벨*2 ; 공격 = 기본공격 + 레벨 ; 방어 = 기본방어 + 레벨 ; 속도 = 기본속도 + 레벨.
// 정수 산술만. 레벨업은 같은 공식을 새 레벨로 다시 평가해 결정론적으로 스탯이 오른다.
// maxHP = baseHP + level*2; ATK = baseATK + level; DEF = baseDEF + level; SPD = baseSPD + level. Integer only.
// Level-up re-evaluates the same formula at the new level so stats rise deterministically.
//
// ── EXP 곡선 / EXP curve ──────────────────────────────────────────────────────────────────────────
// 다음레벨필요경험(레벨) = 레벨*8. 누적 아니고 "현재 레벨에서 다음으로" 필요한 양(현재경험과 비교). 느리되 보이게:
// 강한 적(고레벨·고배율 일격)이면 한 판에 레벨업이 보인다. 적을 쓰러뜨리면 획득경험(적레벨) = 적레벨*14 를 얻는다.
// expToNext(level) = level*8 (per-level requirement). A win grants gainedExp(enemyLevel) = enemyLevel*14 — slow but
// a strong (high-level) win shows a level-up in a single battle.
//
// Key identifiers: 전투원=Combatant, 종에서전투원=combatantFromSpecies, 최대체력=maxHp, 다음레벨필요경험=expToNext,
// 획득경험=gainedExp, 빈전투원=emptyCombatant.
// §17: 크로스모듈 타입(species.종)은 네임스페이스 한정 참조다. 그래서 species 는 모듈째 import 하고 종찾기 도 species.종찾기.
// §17: cross-module types (species.종) are namespace-qualified, so import species whole and call species.종찾기.
import data.species
// P4: 게놈/계통/드리프트. 전투원이 계통시드·드리프트를 들고 다니고, 원본 게놈은 계통시드에서 결정론적으로 유도한다.
// P4: genome/lineage/drift. A combatant carries its lineage seed + drift; the ORIGINAL genome derives from the lineage seed.
import core.genome
import core.util { 범위가두기 }
// 전투원: 한 마리의 전투 상태. 레벨/경험/현재체력은 가변(저장), 나머지 스탯은 레벨에서 유도(직렬화는 현재체력+경험만 보존).
// P4 추가: 계통시드(string)·드리프트(int)는 저장하고, 원본 게놈(genome.게놈)은 계통시드에서 결정론적으로 유도(레벨→스탯과 같은 단일 소스).
// 표현형(보이는 게놈)은 원본 게놈 + 드리프트 너지로 그때그때 계산한다(저장하지 않음 — 드리프트의 순수 함수).
// P5 추가: 세대(int)·진화카운터(int)·형태이름(string)·스탯부스트(int)를 저장한다 — 분기 진화로 영구 고정되는 형태의 상태.
// 세대 = 진화 횟수(0=원형) · 진화카운터 = 진화 무작위 스트림의 per-creature 카운터(진화마다 +1) · 형태이름 = 진화로 파생된 원본 이름("" = 미진화) ·
// 스탯부스트 = 진화 스탯 부스트의 누적(종 기준치 + 레벨 + 부스트로 스탯을 유도 — 진화의 영구 상승을 저장). 진화 시 돌연변이 게놈도 계통시드와 함께 저장.
// 게놈 단일 소스의 예외(P5): 진화하면 게놈이 더 이상 계통시드의 순수 함수가 아니다(돌연변이가 새 기준 게놈). 그래서 게놈을 직렬화에 명시 저장한다.
// A combatant: level/exp/currentHp stored; the rest derive from level. P4 stores lineage seed + drift. P5 ADDS:
// generation (evolution count, 0=original) · evoCounter (per-creature evolution RNG counter, +1 per evolve) ·
// formName (the DERIVED original name; "" = un-evolved) · statBoost (cumulative evolution boost, added to stats like level).
// Single-source exception (P5): once evolved, the genome is no longer a pure function of the lineage seed (the mutation is the
// new base genome), so the genome is now STORED explicitly in serialization (along with form name / generation / boost / counter).
export type 전투원 = {
종아이디: int,
레벨: int,
경험: int,
현재체력: int,
최대체력: int,
공격: int,
방어: int,
속도: int,
속성: int,
스프라이트: int,
기술1: int,
기술2: int,
기술3: int,
기술4: int,
계통시드: string, // P4: 게놈을 유도하는 기원 시드(직렬화에 저장) / lineage seed (stored) — origins the genome
드리프트: int, // P4: 성장으로 천천히 쌓이는 드리프트 카운터(직렬화에 저장) / drift counter (stored)
게놈: genome.게놈, // P4/P5: 기준 게놈 — 미진화면 계통시드에서 유도, 진화하면 돌연변이 게놈(직렬화에 저장) / base genome (stored since P5)
세대: int, // P5: 진화 세대(0=원형, 진화마다 +1) / generation (0 = original, +1 per evolution)
진화카운터: int, // P5: 진화 무작위 스트림의 per-creature 카운터(진화마다 +1) / per-creature evolution RNG counter
형태이름: string, // P5: 진화로 파생된 원본 형태 이름("" = 미진화, 종 이름으로 폴백) / derived form-name ("" = un-evolved)
스탯부스트: int // P5: 누적 진화 스탯 부스트(스탯 유도에 더해짐) / cumulative evolution stat boost
}
// 레벨에서 최대체력을 유도한다. / Derive maxHP from level.
export function 최대체력(종정보: species.종, 레벨: int) -> int {
종정보.기본체력 + 레벨 * 2
}
// 다음 레벨로 가는 데 필요한 경험치(현재 레벨 기준). / Exp required to reach the next level (from the current level).
export function 다음레벨필요경험(레벨: int) -> int {
레벨 * 8
}
// 적을 쓰러뜨려 얻는 경험치(적 레벨 비례). / Exp gained for defeating an enemy (proportional to its level).
export function 획득경험(적레벨: int) -> int {
적레벨 * 14
}
// 레벨을 [1, 99]로 가둔다 — 손상·조작된 STATE의 거대 레벨이 데미지 산술의 checked-int를 넘기지 않게(역직렬화 방어선).
// Clamp level to [1, 99] so a corrupt/crafted STATE's huge level cannot overflow the checked-int damage math.
export function 레벨가두기(레벨: int) -> int {
범위가두기(레벨, 1, 99)
}
// 종 + 레벨 + 계통시드 → 전투원(체력 가득, 드리프트 0). 스탯은 종 기준치에서, 원본 게놈은 계통시드에서 결정론적으로 유도.
// 새로 태어난(시작종) / 갓 잡은 야생이 쓰는 생성자. 같은 계통시드 → 같은 게놈(재현). 드리프트는 0에서 시작(성장으로 쌓인다).
// P5: 새로 태어난 크리처는 미진화(세대 0·진화카운터 0·형태이름 ""·스탯부스트 0). 진화는 P5의 분기진화에서만 일어난다.
// Species + level + lineage seed -> a full-HP combatant, drift 0. Stats derive from species base; the ORIGINAL genome derives
// from the lineage seed. P5: newly born creatures are un-evolved (generation 0, evoCounter 0, formName "", statBoost 0).
export function 종에서전투원(아이디: int, 레벨: int, 계통시드: string) -> 전투원 {
let 종정보 = species.종찾기(아이디)
let 레벨c = 레벨가두기(레벨)
let 맥스 = 최대체력(종정보, 레벨c)
{
종아이디: 종정보.아이디,
레벨: 레벨c,
경험: 0,
현재체력: 맥스,
최대체력: 맥스,
공격: 종정보.기본공격 + 레벨c,
방어: 종정보.기본방어 + 레벨c,
속도: 종정보.기본속도 + 레벨c,
속성: 종정보.속성,
스프라이트: 종정보.스프라이트,
기술1: 종정보.기술1,
기술2: 종정보.기술2,
기술3: 종정보.기술3,
기술4: 종정보.기술4,
계통시드: 계통시드,
드리프트: 0,
게놈: genome.게놈유도(계통시드),
세대: 0,
진화카운터: 0,
형태이름: "",
스탯부스트: 0
}
}
// 세대/진화카운터/스탯부스트 안전 상한. 조작·오버플로 방어(역직렬화 방어선). / Clamps for generation / evo-counter / stat-boost.
// 세대 [0,99] · 진화카운터 [0,1e6] · 스탯부스트 [0,400](레벨 99 + 부스트 400 도 데미지 산술 한참 아래). / each bounds the math.
export function 세대가두기(세대: int) -> int {
범위가두기(세대, 0, 99)
}
export function 진화카운터가두기(카운터: int) -> int {
범위가두기(카운터, 0, 1000000)
}
export function 스탯부스트가두기(부스트: int) -> int {
범위가두기(부스트, 0, 400) // 진화 1회당 +2..5, 세대 99 까지여도 ≤ 495 — 400 으로 가둬 공격 ≤ ~500, 데미지 산술 안전. / capped well under the math bound
}
// 저장된 (종아이디·레벨·경험·현재체력·계통시드·드리프트 + P5: 세대·진화카운터·형태이름·스탯부스트·게놈) 으로 전투원을 복원한다.
// 라운드트립의 단일 소스: 스탯=레벨+부스트. 게놈: 미진화(세대 0)면 계통시드에서 재유도, 진화(세대>0)면 저장된 돌연변이 게놈을 쓴다.
// 현재체력 [0,최대], 드리프트/세대/진화카운터/부스트 모두 가둔다(오버플로/조작 방어). 진화 전투원도 byte-동일하게 라운드트립.
// Rebuild a combatant. Stats = level + boost. Genome: un-evolved (gen 0) re-derives from the lineage seed; evolved (gen>0)
// uses the STORED mutated genome. All counters clamped. Evolved combatants round-trip byte-identically.
export function 전투원복원(아이디: int, 레벨: int, 경험: int, 현재체력: int, 계통시드: string, 드리프트: int, 세대: int, 진화카운터: int, 형태이름: string, 스탯부스트: int, 게놈: genome.게놈) -> 전투원 {
let 종정보 = species.종찾기(아이디)
let 레벨c = 레벨가두기(레벨)
let 세대c = 세대가두기(세대)
let 부스트c = 스탯부스트가두기(스탯부스트)
let 맥스 = 최대체력(종정보, 레벨c) + 부스트c * 2 // 진화 부스트로 최대체력 영구 상승(분기진화와 동일 공식) / max HP up by the boost
let 체력 = 범위가두기(현재체력, 0, 맥스)
// 게놈: 미진화면 계통시드에서 재유도(P4 단일 소스), 진화면 저장된 돌연변이 게놈을 그대로(새 기준 게놈). / un-evolved -> derive; evolved -> stored mutation.
let 기준게놈 = if 세대c <= 0 { genome.게놈유도(계통시드) } else { 게놈 }
{
종아이디: 종정보.아이디,
레벨: 레벨c,
경험: 경험,
현재체력: 체력,
최대체력: 맥스,
공격: 종정보.기본공격 + 레벨c + 부스트c,
방어: 종정보.기본방어 + 레벨c + 부스트c,
속도: 종정보.기본속도 + 레벨c + 부스트c,
속성: 종정보.속성,
스프라이트: 종정보.스프라이트,
기술1: 종정보.기술1,
기술2: 종정보.기술2,
기술3: 종정보.기술3,
기술4: 종정보.기술4,
계통시드: 계통시드,
드리프트: genome.드리프트가두기(드리프트),
게놈: 기준게놈,
세대: 세대c,
진화카운터: 진화카운터가두기(진화카운터),
형태이름: 형태이름,
스탯부스트: 부스트c
}
}
// 전투원을 완전 회복시킨다(현재체력 = 최대체력). 레벨·경험·드리프트·진화 형태·게놈은 그대로 — 휴식은 진척을 지우지 않는다.
// Full-heal a combatant (current HP = max HP); level/exp/drift/evolved-form/genome are preserved (rest never erases progress).
export function 회복(원: 전투원) -> 전투원 {
전투원복원(원.종아이디, 원.레벨, 원.경험, 원.최대체력, 원.계통시드, 원.드리프트, 원.세대, 원.진화카운터, 원.형태이름, 원.스탯부스트, 원.게놈)
}
// 현재체력만 바꾼 전투원(압력전 안정도 갱신용). 전투원복원이 [0, 최대]로 가둔다. / a combatant with only current HP changed (clash cohesion); 전투원복원 clamps to [0, max].
export function 체력설정(원: 전투원, 새체력: int) -> 전투원 {
전투원복원(원.종아이디, 원.레벨, 원.경험, 새체력, 원.계통시드, 원.드리프트, 원.세대, 원.진화카운터, 원.형태이름, 원.스탯부스트, 원.게놈)
}
// 드리프트만 바꾸고 완전 회복시킨 전투원(패배 학습용 — 계통이 패배에서 드리프트를 얻어 적응한다). / set drift + full heal (learning from defeat).
export function 드리프트설정(원: 전투원, 새드리프트: int) -> 전투원 {
전투원복원(원.종아이디, 원.레벨, 원.경험, 원.최대체력, 원.계통시드, genome.드리프트가두기(새드리프트), 원.세대, 원.진화카운터, 원.형태이름, 원.스탯부스트, 원.게놈)
}
// 빈 전투원(파티 자리 없음). 종아이디 0 = 없음. 게놈은 빈 계통시드에서 유도, 드리프트 0, 미진화. / An empty combatant (no party slot).
export function 빈전투원() -> 전투원 {
{
종아이디: 0, 레벨: 0, 경험: 0, 현재체력: 0, 최대체력: 0,
공격: 0, 방어: 0, 속도: 0, 속성: 0, 스프라이트: 0,
기술1: 0, 기술2: 0, 기술3: 0, 기술4: 0,
계통시드: "", 드리프트: 0, 게놈: genome.게놈유도(""),
세대: 0, 진화카운터: 0, 형태이름: "", 스탯부스트: 0
}
}
// ===== core/battle.tpz =====
// 배틀 엔진(순수) — P2의 심장. 턴 한 번을 결정론적으로 굴린다: 속도 순서(동률은 PRNG) → 양측 행동 → 데미지(레벨·공/방·
// 타입상성·작은 PRNG 변동)·명중 굴림 → 체력 감소 → 기절 → 승리 시 EXP·레벨업. 모든 난수는 PRNG의 BATTLE 도메인에서,
// STATE에 스레딩된 전투카운터를 행동마다 1씩 올려 뽑는다 → 배틀이 조우/돌연변이 스트림을 흔들지 않고 (시드+입력)으로 재현된다.
// Battle engine (pure) — the heart of P2. Resolves one turn deterministically: turn order by SPD (PRNG tie-break) ->
// both sides act -> damage (level/ATK/DEF/type-effectiveness/a small PRNG variance) + an accuracy roll -> HP depletion ->
// FAINT -> on a win, EXP + level-up. ALL randomness comes from the PRNG's BATTLE domain, drawing on a battle counter
// threaded in STATE that advances +1 per action -> battle rolls never perturb the encounter/mutation streams, and the
// whole battle reproduces from (seed + the player's inputs).
//
// 하우스룰(core): export는 타입/함수/불변 최상위만. 최상위 가변·print·input·host(now_millis·파일IO·미시드난수) 금지.
// House rule (core): export only types/functions/immutable bindings; no module-level mutable/print/input/host. deps inward (battle->rng+creature+data).
//
// ── 데미지 공식(부동소수 없음) / damage formula (no floats) ──────────────────────────────────────────
// 기초 = (((2*레벨 / 5 + 2) * 위력 * 공격 / 방어) / 25) + 2 ← 정수 나눗셈만(레벨/공/방/위력에서 유도)
// 효과적용 = 기초 * 효과배율2 / 2 ← x2→*4/2, x1→*2/2, x0.5→*1/2 (정수)
// 변동 = 85..100 의 PRNG 굴림 ← 같은 입력도 매번 다르게(전투카운터로)
// 데미지 = 효과적용 * 변동 / 100 (최소 1) ← 항상 ≥1 (0데미지 방지)
// base = (((2*level/5 + 2) * power * ATK / DEF) / 25) + 2; eff = base*eff2/2; variance = PRNG 85..100; dmg = eff*variance/100 (min 1).
// 중간값 한계: level≤99, power≤60, ATK≤200, DEF≥1 → (200)*60*200 ≈ 2.4e6, *eff2(4)=9.6e6, *variance(100)=9.6e8 ≪ 9.22e18 (안전).
//
// ── 명중 / accuracy ───────────────────────────────────────────────────────────────────────────────
// 명중굴림(0..99) < 기술.명중 이면 적중, 아니면 빗나감. 굴림은 전투 도메인·전투카운터로(행동마다 카운터 +1).
// A 0..99 roll < move.accuracy hits, else misses. The roll uses the battle domain + battle counter (counter +1 per action).
//
// ── 전투 서브 상태기 / battle sub-state machine ─────────────────────────────────────────────────────
// menu(싸운다/도망간다) → move(기술 선택) → resolve(속도 순서로 양측 행동) → message(줄별 a로 진행)
// → (menu 로 루프 | field 로 종료: 승리/패배/도주). RUN = 속도 기반 탈출 굴림.
// menu(FIGHT/RUN) -> move(select) -> resolve(both act by SPD) -> message(advance per a) -> (loop to menu | end to field).
//
// Key identifiers: 배틀상태=BattleState, 턴결과=TurnResult, 전투카운터=battleCounter, 효과배율2=effectiveness2,
// 데미지계산=computeDamage, 명중판정=rollHit, 턴진행=advanceTurn, 도주판정=rollRun, 메시지결합=joinMessages.
// §17: 크로스모듈 타입(creature.전투원·moves.기술)은 네임스페이스 한정 참조다. 그래서 두 모듈은 모듈째 import 하고,
// 값 함수도 같은 한정 이름(creature.다음레벨필요경험 등)으로 부른다. / §17: cross-module types are namespace-qualified.
import core.rng { 난수범위 }
import core.creature
import core.genome { 드리프트증가, 드리프트가두기 }
import core.evolution { 진화발사 } // P5: 진화 발사 판정(준비 + 레벨업)을 win 처리에서 쓴다 / P5 evolution trigger (ready + leveled) used on a win
import core.util { 범위가두기, 하한가두기, 상한가두기 }
import data.species { 종찾기 }
import data.moves
import data.types { 효과배율2 }
// 배틀 서브 상태(STATE 안에 직렬화된다). 메시지는 ";;" 로 이은 한 줄 + 커서 인덱스로 한 줄씩 진행한다.
// Battle sub-state (serialized inside STATE). Messages are one ";;"-joined string + a cursor advanced one line per 'a'.
// 단계=phase: "menu" | "move" | "message" | "clash" | "result"
// 메뉴커서=menu cursor (0 싸운다 / 1 도망간다) · 기술커서=move cursor (0..3)
// 전투카운터=battle PRNG counter (행동마다 +1; 배틀 재현의 핵심) · 적체력=enemy current HP
// 메시지=";;"-joined lines · 메시지커서=line cursor · 다음단계=where to go when messages run out ("menu"/"field"/"catch"/"evolve"/"clash")
// 결과=outcome tag for the shell: "" 진행중 · "win" · "lose" · "fled" · "caught"
export type 배틀단계 = "menu" | "move" | "message" | "clash" | "result"
export type 배틀다음단계 = "menu" | "field" | "catch" | "evolve" | "clash"
export type 배틀결과 = "" | "win" | "lose" | "fled" | "caught"
// 배틀 단계 토큰을 닫힌 태그로 좁힌다(레거시 move/message 포함). 손상 값은 메뉴로 복귀한다.
// Narrow a serialized battle phase into the closed tag (including legacy move/message). Corrupt values return to menu.
export function 단계파싱(토큰: string) -> 배틀단계 {
match 토큰 {
case "menu" => "menu"
case "move" => "move"
case "message" => "message"
case "clash" => "clash"
case "result" => "result"
case _ => "menu"
}
}
// 배틀 후속 단계 토큰을 닫힌 태그로 좁힌다. 손상 값은 메뉴로 복귀한다.
// Narrow a serialized next-stage token. Corrupt values return to menu.
export function 다음단계파싱(토큰: string) -> 배틀다음단계 {
match 토큰 {
case "menu" => "menu"
case "field" => "field"
case "catch" => "catch"
case "evolve" => "evolve"
case "clash" => "clash"
case _ => "menu"
}
}
// 배틀 결과 토큰을 닫힌 태그로 좁힌다. 손상 값은 진행중("")으로 둔다.
// Narrow a serialized battle result token. Corrupt values become in-progress ("").
export function 결과파싱(토큰: string) -> 배틀결과 {
match 토큰 {
case "" => ""
case "win" => "win"
case "lose" => "lose"
case "fled" => "fled"
case "caught" => "caught"
case _ => ""
}
}
export type 배틀상태 = {
단계: 배틀단계,
메뉴커서: int,
기술커서: int,
전투카운터: int,
적체력: int,
메시지: string,
메시지커서: int,
다음단계: 배틀다음단계,
결과: 배틀결과
}
// 빈 배틀 상태(필드일 때 자리채움). / Empty battle sub-state (placeholder in the field scene).
export function 빈배틀() -> 배틀상태 {
{ 단계: "menu", 메뉴커서: 0, 기술커서: 0, 전투카운터: 0, 적체력: 0,
메시지: "", 메시지커서: 0, 다음단계: "menu", 결과: "" }
}
// 배틀 시작 상태: 적 야생의 현재 체력 = 적 최대체력, 메뉴 단계, 전투카운터는 (조우 카운터에서 파생되어) 넘겨받는다.
// Initial battle sub-state: enemy currentHP = enemy maxHP, phase = menu, the battle counter is handed in (derived from the encounter).
export function 배틀시작(적최대체력: int, 시작카운터: int) -> 배틀상태 {
{ 단계: "menu", 메뉴커서: 0, 기술커서: 0, 전투카운터: 시작카운터, 적체력: 적최대체력,
메시지: "", 메시지커서: 0, 다음단계: "menu", 결과: "" }
}
// ── 데미지·명중·속도 ────────────────────────────────────────────────────────────────────────────
// 데미지 공식(정수만, 위 주석 참조). 효과배율2 ∈ {1,2,4} 를 곱하고 /2 → x0.5/x1/x2 를 부동소수 없이.
// Damage formula (integer-only). Multiply by 효과배율2 ∈ {1,2,4} then /2 -> x0.5 / x1 / x2 with no floats.
export function 데미지계산(공격레벨: int, 위력: int, 공격력: int, 방어력: int, 효과: int, 변동: int) -> int {
let 안전방어 = if 방어력 < 1 { 1 } else { 방어력 }
let 기초 = (((2 * 공격레벨 / 5 + 2) * 위력 * 공격력 / 안전방어) / 25) + 2
let 효과적용 = 기초 * 효과 / 2
let 데미지 = 효과적용 * 변동 / 100
if 데미지 < 1 { 1 } else { 데미지 }
}
// 효과 메시지(x2/x0.5/x1). 부동소수 없이 효과배율2 코드로 분기. / Effectiveness message from the ×2 code (no floats).
export function 효과문구(효과: int) -> string {
if 효과 == 4 { return "효과가 굉장합니다!" }
if 효과 == 1 { return "효과가 별로인 듯합니다." }
""
}
// ── 한 측의 공격 한 번을 굴린다(명중→데미지). 카운터를 받아 명중·변동에 쓰고, 쓴 만큼 올린 새 카운터를 돌려준다. ──
// Resolve one side's single attack (accuracy -> damage). Takes the counter for the accuracy/variance rolls and
// returns the advanced counter. 결과는 (적중여부·데미지·효과·새카운터). / Result = (hit, damage, effectiveness, newCounter).
export type 공격결과 = { 적중: bool, 데미지: int, 효과: int, 새카운터: int }
export function 공격굴림(시드: string, 카운터: int, 기술아이디: int, 공격레벨: int, 공격력: int, 방어력: int, 방어속성: int) -> 공격결과 {
let 기술정보 = moves.기술찾기(기술아이디)
// 명중 굴림(0..99) < 명중률 이면 적중. 도메인 "battle", 엔티티 = 기술아이디. 카운터 사용 후 +1.
// Accuracy roll (0..99) < accuracy hits. Domain "battle", entity = move id. Counter used, then +1.
let 명중굴림 = 난수범위("battle", 기술아이디, 카운터, 시드, 0, 99)
let 카운터1 = 카운터 + 1
if 명중굴림 >= 기술정보.명중 {
return { 적중: false, 데미지: 0, 효과: 2, 새카운터: 카운터1 }
}
// 타입 상성: 무속성 기술(99)은 항상 보통(2). / Type effectiveness; a typeless move (99) is always neutral (2).
let 효과 = if 기술정보.속성 == moves.무속성코드() { 2 } else { 효과배율2(기술정보.속성, 방어속성) }
// 변동 굴림 85..100. 엔티티를 기술아이디+7 로 어긋내 명중 굴림과 다른 점을 보게 한다(같은 카운터, 다른 엔티티 = 독립).
// Variance roll 85..100. Offset the entity by +7 so it differs from the accuracy roll at the same counter (independent stream).
let 변동 = 난수범위("battle", 기술아이디 + 7, 카운터, 시드, 85, 100)
let 데미지 = 데미지계산(공격레벨, 기술정보.위력, 공격력, 방어력, 효과, 변동)
{ 적중: true, 데미지: 데미지, 효과: 효과, 새카운터: 카운터1 }
}
// 적 AI: 적의 첫 비어있지 않은 기술을 고른다(P2는 단순 — 슬롯1; 상태/AI 고급화는 후속). 0이면 몸통박치기(1).
// Enemy AI: pick the enemy's first non-empty move (P2 keeps it simple — slot 1; smarter AI is later). 0 -> 몸통박치기 (1).
export function 적기술선택(적종아이디: int) -> int {
let 종정보 = 종찾기(적종아이디)
if 종정보.기술1 != 0 { 종정보.기술1 } else { 1 }
}
// 속도 순서: 플레이어가 먼저면 true. 속도 큰 쪽이 먼저; 동률은 전투 도메인 PRNG 로 깬다(카운터 사용 후 +1).
// Turn order: returns (playerFirst, newCounter). Higher SPD goes first; ties broken by the battle-domain PRNG (counter +1).
export type 순서결과 = { 플레이어먼저: bool, 새카운터: int }
export function 속도순서(시드: string, 카운터: int, 플레이어속도: int, 적속도: int) -> 순서결과 {
if 플레이어속도 > 적속도 { return { 플레이어먼저: true, 새카운터: 카운터 } }
if 플레이어속도 < 적속도 { return { 플레이어먼저: false, 새카운터: 카운터 } }
// 동률: 0/1 굴림. 0 = 플레이어 먼저. / Tie: a 0/1 roll. 0 -> player first.
let 굴림 = 난수범위("battle", 0, 카운터, 시드, 0, 1)
{ 플레이어먼저: (굴림 == 0), 새카운터: 카운터 + 1 }
}
// 도주 판정: 속도 기반(플레이어 빠를수록 잘 도망). 굴림 0..99 < 도주확률 이면 성공. 카운터 사용 후 +1.
// Run roll: SPD-based (faster player escapes more often). A 0..99 roll < escapeChance succeeds. Counter +1.
// 도주확률 = 50 + (플레이어속도 - 적속도)*10, [10,95] 로 가둠. / escapeChance = 50 + (pSpd-eSpd)*10, clamped to [10,95].
export type 도주결과 = { 성공: bool, 새카운터: int }
export function 도주판정(시드: string, 카운터: int, 플레이어속도: int, 적속도: int) -> 도주결과 {
let 확률 = 범위가두기(50 + (플레이어속도 - 적속도) * 10, 10, 95)
let 굴림 = 난수범위("battle", 1, 카운터, 시드, 0, 99)
{ 성공: (굴림 < 확률), 새카운터: 카운터 + 1 }
}
// ── 레벨업 / level-up ──────────────────────────────────────────────────────────────────────────
// 경험을 더하고, 임계(다음레벨필요경험)를 넘는 동안 레벨을 올린다(여러 레벨업 가능). 레벨업 시 최대체력 증가분만큼 현재체력도 회복.
// Add exp, and while it crosses the threshold (expToNext), level up (possibly several). On level-up, currentHP rises by the maxHP gain.
// 결과 = (레벨오른 전투원·오른레벨수). / Result = (leveled combatant, number of levels gained).
export type 성장결과 = { 전투원: creature.전투원, 오른레벨: int }
export function 경험적용(대상: creature.전투원, 얻은경험: int, 데모: bool) -> 성장결과 {
let 종정보 = 종찾기(대상.종아이디)
let mut 레벨 = 대상.레벨
let mut 경험 = 대상.경험 + 얻은경험
let mut 체력 = 대상.현재체력
let mut 오른 = 0
let mut 계속 = true
// 안전 상한(최대 20레벨업/판) + 레벨 99 상한 — 무한루프 방지 + 성장이 레벨을 99 너머로 밀어 데미지 산술을 오버플로하지 않게.
// Safety cap (20 level-ups/battle) + a hard level ceiling of 99, so growth can't push level past the clamp and overflow the damage math.
let mut 가드 = 0
while 계속 && 가드 < 20 && 레벨 < 99 {
let 필요 = creature.다음레벨필요경험(레벨)
if 경험 >= 필요 {
let 이전최대 = creature.최대체력(종정보, 레벨)
경험 = 경험 - 필요
레벨 = 레벨 + 1
let 새최대 = creature.최대체력(종정보, 레벨)
체력 = 체력 + (새최대 - 이전최대) // 최대체력 증가분만큼 현재체력도 올림 / heal by the maxHP gain
오른 = 오른 + 1
가드 = 가드 + 1
} else {
계속 = false
}
}
// P5: 진화 스탯부스트는 레벨업을 넘어 영구 유지된다(최대체력 = 종 기준치 + 부스트*2, 스탯 = +레벨 +부스트). 부스트는 상수라 위의 HP 증가 델타에는 영향 없음.
// P5: the evolution stat boost persists across level-ups (maxHP += boost*2; stats += boost). Being constant, it doesn't affect the per-level HP deltas above.
let 부스트 = 대상.스탯부스트
let 새최대 = creature.최대체력(종정보, 레벨) + 부스트 * 2
let 새체력 = 상한가두기(체력, 새최대)
// P4 드리프트: 승리(경험 획득)는 항상 드리프트를 더한다 — 레벨업 1당 +2, 레벨업 없는 승리는 +1. 천천히 쌓이게(브랜드: slow.monster).
// 같은 시드+같은 입력 → 같은 오른레벨 → 같은 드리프트 증가(결정론). 가두기로 상한 방어. / drift accrues on every win; clamped.
// P4 drift: a win (gaining exp) always adds drift — +2 per level gained, +1 for a win with no level-up. Deterministic & clamped.
// 데모 플래그는 SHELL 에서 여기까지 스레딩된다 — 데모=true 면 드리프트가 빨리 쌓여 ~2승에 진화(데모=false 면 위 느린 값 그대로).
// The demo flag is threaded from SHELL down to here; demo=true makes drift accrue fast (~2 wins to evolve); demo=false keeps the slow values.
let 새드리프트 = 드리프트가두기(대상.드리프트 + 드리프트증가(오른, true, 데모))
{
전투원: 대상{
레벨: 레벨,
경험: 경험,
현재체력: 새체력,
최대체력: 새최대,
공격: 종정보.기본공격 + 레벨 + 부스트,
방어: 종정보.기본방어 + 레벨 + 부스트,
속도: 종정보.기본속도 + 레벨 + 부스트,
드리프트: 새드리프트
},
오른레벨: 오른
}
}
// ── 메시지 헬퍼 / message helpers ─────────────────────────────────────────────────────────────────
// 메시지는 ";;" 로 이은 한 문자열이다(STATE 직렬화에 파이프/줄바꿈을 피하려고). 빈 줄은 건너뛴다.
// Messages are a single ";;"-joined string (avoids pipes/newlines in the STATE serialization). Empty pieces are skipped.
function 메시지추가(누적: string, 줄: string) -> string {
if 줄 == "" { return 누적 }
if 누적 == "" { 줄 } else { "{누적};;{줄}" }
}
// 메시지 줄 개수(";;" 로 이어진). 빈 문자열은 0줄. / Count of ";;"-joined message lines (empty string -> 0 lines).
export function 메시지수(메시지: string) -> int {
if 메시지 == "" { return 0 }
메시지.split(";;").length
}
// 커서 위치의 메시지 한 줄을 돌려준다(범위 밖이면 빈 문자열). 드로우리스트가 현재 줄을 표시하는 데 쓴다.
// Return the message line at the cursor (empty if out of range). The draw-list uses this to show the current line.
export function 메시지줄(메시지: string, 커서: int) -> string {
if 메시지 == "" { return "" }
let 줄들 = 메시지.split(";;")
if 커서 < 0 || 커서 >= 줄들.length { return "" }
줄들[커서]
}
// ── 도주 턴: 속도 기반 탈출. 성공 → 필드 복귀(다음단계 field, 결과 fled). 실패 → 적이 한 번 공격하고 메뉴로. ──
// Run turn: SPD-based escape. Success -> back to field. Failure -> the enemy gets one free attack, then back to the menu.
export function 도주턴(시드: string, 배틀: 배틀상태, 플레이어: creature.전투원, 야생종아이디: int, 야생레벨: int) -> 턴결과 {
let 야생종 = 종찾기(야생종아이디)
let 야생공격 = 야생종.기본공격 + 야생레벨
let 야생속도 = 야생종.기본속도 + 야생레벨
let 야생이름 = 야생종.이름
let 야생기술 = 적기술선택(야생종아이디)
let 야생기술이름 = moves.기술찾기(야생기술).이름
let 플레이어종 = 종찾기(플레이어.종아이디)
let 플레이어이름 = 플레이어종.이름
let mut 카운터 = 배틀.전투카운터
let 도주 = 도주판정(시드, 카운터, 플레이어.속도, 야생속도)
카운터 = 도주.새카운터
if 도주.성공 {
return {
배틀: 배틀{ 단계: "message", 메뉴커서: 0, 전투카운터: 카운터,
메시지: "무사히 도망쳤습니다!", 메시지커서: 0, 다음단계: "field", 결과: "fled" },
플레이어: 플레이어
}
}
// 도주 실패: 적이 한 번 공격한다(빈틈). / Run failed: the enemy gets one free attack.
let mut 메시지 = 메시지추가("", "하지만 도망치지 못했습니다!")
let 공 = 공격굴림(시드, 카운터, 야생기술, 야생레벨, 야생공격, 플레이어.방어, 플레이어.속성)
카운터 = 공.새카운터
메시지 = 메시지추가(메시지, "야생 {야생이름}의 {야생기술이름}!")
let mut 플레이어체력 = 플레이어.현재체력
if 공.적중 {
플레이어체력 = 하한가두기(플레이어체력 - 공.데미지, 0)
메시지 = 메시지추가(메시지, 효과문구(공.효과))
} else {
메시지 = 메시지추가(메시지, "하지만 빗나갔습니다.")
}
let mut 다음단계: 배틀다음단계 = "menu"
let mut 결과: 배틀결과 = ""
if 플레이어체력 <= 0 {
메시지 = 메시지추가(메시지, "{플레이어이름}이(가) 쓰러졌습니다...")
메시지 = 메시지추가(메시지, "눈앞이 캄캄해졌습니다.")
다음단계 = "field"
결과 = "lose"
}
let 새플레이어 = 플레이어{ 현재체력: 플레이어체력 }
{
배틀: 배틀{ 단계: "message", 메뉴커서: 0, 전투카운터: 카운터,
메시지: 메시지, 메시지커서: 0, 다음단계: 다음단계, 결과: 결과 },
플레이어: 새플레이어
}
}
// ── 적 빈틈 턴: 포획 실패처럼 플레이어가 행동을 허비했을 때 적이 한 번 공격한다. 선두메시지(";;"로 이은) 뒤에 적 공격을 잇는다. ──
// Enemy free turn: when the player wasted an action (e.g. a failed capture), the enemy gets one free attack.
// Takes a leading message (";;"-joined) and appends the enemy's attack lines. All randomness in the BATTLE domain (counter +1 per action).
export function 적빈틈턴(시드: string, 배틀: 배틀상태, 플레이어: creature.전투원, 야생종아이디: int, 야생레벨: int, 선두메시지: string) -> 턴결과 {
let 야생종 = 종찾기(야생종아이디)
let 야생공격 = 야생종.기본공격 + 야생레벨
let 야생이름 = 야생종.이름
let 야생기술 = 적기술선택(야생종아이디)
let 야생기술이름 = moves.기술찾기(야생기술).이름
let 플레이어종 = 종찾기(플레이어.종아이디)
let 플레이어이름 = 플레이어종.이름
let mut 카운터 = 배틀.전투카운터
let mut 메시지 = 선두메시지
let 공 = 공격굴림(시드, 카운터, 야생기술, 야생레벨, 야생공격, 플레이어.방어, 플레이어.속성)
카운터 = 공.새카운터
메시지 = 메시지추가(메시지, "야생 {야생이름}의 {야생기술이름}!")
let mut 플레이어체력 = 플레이어.현재체력
if 공.적중 {
플레이어체력 = 하한가두기(플레이어체력 - 공.데미지, 0)
메시지 = 메시지추가(메시지, 효과문구(공.효과))
} else {
메시지 = 메시지추가(메시지, "하지만 빗나갔습니다.")
}
let mut 다음단계: 배틀다음단계 = "menu"
let mut 결과: 배틀결과 = ""
if 플레이어체력 <= 0 {
메시지 = 메시지추가(메시지, "{플레이어이름}이(가) 쓰러졌습니다...")
메시지 = 메시지추가(메시지, "눈앞이 캄캄해졌습니다.")
다음단계 = "field"
결과 = "lose"
}
let 새플레이어 = 플레이어{ 현재체력: 플레이어체력 }
{
배틀: 배틀{ 단계: "message", 메뉴커서: 0, 전투카운터: 카운터,
메시지: 메시지, 메시지커서: 0, 다음단계: 다음단계, 결과: 결과 },
플레이어: 새플레이어
}
}
// ── 턴 진행: menu 에서 '싸운다 + 기술선택' 이 들어오면 한 턴을 완전히 굴린다(속도 순서로 양측 행동). ──
// Resolve a full turn from a chosen player move: turn order by SPD, both sides act, HP depletes, faint/win/lose/level-up.
// 입력: 시드·배틀상태·플레이어전투원·야생(종아이디·레벨·속성·스프라이트는 야생크리처에서, 스탯은 종에서 유도)·선택기술아이디.
// 출력: (새 배틀상태·새 플레이어전투원). 적 현재체력은 배틀상태.적체력 에, 플레이어 체력은 전투원.현재체력 에 산다.
// Result = (new battle state, new player combatant). Enemy HP lives in 배틀상태.적체력; player HP in 전투원.현재체력.
export type 턴결과 = { 배틀: 배틀상태, 플레이어: creature.전투원 }
export function 턴진행(시드: string, 배틀: 배틀상태, 플레이어: creature.전투원, 야생종아이디: int, 야생레벨: int, 선택기술: int, 데모: bool) -> 턴결과 {
// 야생을 전투원으로 유도(스탯은 종+레벨에서). 적 현재체력은 배틀상태에서 가져온다(턴 사이 보존).
// Derive the wild's stats from species + level; its current HP comes from the battle state (persists across turns).
let 야생종 = 종찾기(야생종아이디)
let 야생속성 = 야생종.속성
let 야생공격 = 야생종.기본공격 + 야생레벨
let 야생방어 = 야생종.기본방어 + 야생레벨
let 야생속도 = 야생종.기본속도 + 야생레벨
let 야생기술 = 적기술선택(야생종아이디)
let mut 카운터 = 배틀.전투카운터
let mut 플레이어체력 = 플레이어.현재체력
let mut 적체력 = 배틀.적체력
let mut 메시지 = ""
// 속도 순서(동률 PRNG). / Turn order (PRNG tie-break).
let 순서 = 속도순서(시드, 카운터, 플레이어.속도, 야생속도)
카운터 = 순서.새카운터
let 플레이어먼저 = 순서.플레이어먼저
// 플레이어 종 이름(메시지용). / Player species name (for messages).
let 플레이어종 = 종찾기(플레이어.종아이디)
let 플레이어이름 = 플레이어종.이름
let 야생이름 = 야생종.이름
let 플레이어기술이름 = moves.기술찾기(선택기술).이름
let 야생기술이름 = moves.기술찾기(야생기술).이름
// 한 측의 공격을 적용하는 지역 절차를 두 번(순서대로) 호출한다. 토파즈엔 클로저가 없으니 인라인 블록으로 펼친다.
// We inline each side's attack twice (in turn order) — no closures in Topaz, so the logic is unrolled per side.
let mut 종료 = false
let mut 결과: 배틀결과 = ""
// 첫 번째 행동자.
if 플레이어먼저 {
let 공 = 공격굴림(시드, 카운터, 선택기술, 플레이어.레벨, 플레이어.공격, 야생방어, 야생속성)
카운터 = 공.새카운터
메시지 = 메시지추가(메시지, "{플레이어이름}의 {플레이어기술이름}!")
if 공.적중 {
적체력 = 하한가두기(적체력 - 공.데미지, 0)
메시지 = 메시지추가(메시지, 효과문구(공.효과))
} else {
메시지 = 메시지추가(메시지, "하지만 빗나갔습니다.")
}
if 적체력 <= 0 { 종료 = true; 결과 = "win" }
} else {
let 공 = 공격굴림(시드, 카운터, 야생기술, 야생레벨, 야생공격, 플레이어.방어, 플레이어.속성)
카운터 = 공.새카운터
메시지 = 메시지추가(메시지, "야생 {야생이름}의 {야생기술이름}!")
if 공.적중 {
플레이어체력 = 하한가두기(플레이어체력 - 공.데미지, 0)
메시지 = 메시지추가(메시지, 효과문구(공.효과))
} else {
메시지 = 메시지추가(메시지, "하지만 빗나갔습니다.")
}
if 플레이어체력 <= 0 { 종료 = true; 결과 = "lose" }
}
// 두 번째 행동자(첫 번째에서 끝나지 않았으면).
if !종료 {
if 플레이어먼저 {
let 공 = 공격굴림(시드, 카운터, 야생기술, 야생레벨, 야생공격, 플레이어.방어, 플레이어.속성)
카운터 = 공.새카운터
메시지 = 메시지추가(메시지, "야생 {야생이름}의 {야생기술이름}!")
if 공.적중 {
플레이어체력 = 하한가두기(플레이어체력 - 공.데미지, 0)
메시지 = 메시지추가(메시지, 효과문구(공.효과))
} else {
메시지 = 메시지추가(메시지, "하지만 빗나갔습니다.")
}
if 플레이어체력 <= 0 { 종료 = true; 결과 = "lose" }
} else {
let 공 = 공격굴림(시드, 카운터, 선택기술, 플레이어.레벨, 플레이어.공격, 야생방어, 야생속성)
카운터 = 공.새카운터
메시지 = 메시지추가(메시지, "{플레이어이름}의 {플레이어기술이름}!")
if 공.적중 {
적체력 = 하한가두기(적체력 - 공.데미지, 0)
메시지 = 메시지추가(메시지, 효과문구(공.효과))
} else {
메시지 = 메시지추가(메시지, "하지만 빗나갔습니다.")
}
if 적체력 <= 0 { 종료 = true; 결과 = "win" }
}
}
// 턴 종료 후 후처리: 승리(EXP·레벨업) / 패배(블랙아웃) / 계속(메뉴로). 플레이어 체력을 전투원에 반영.
// Post-turn: win(EXP + level-up) / lose(blackout) / continue(back to menu). Reflect player HP into the combatant.
let mut 새플레이어 = 플레이어{ 현재체력: 플레이어체력 }
let mut 다음단계: 배틀다음단계 = "menu"
let mut 최종결과: 배틀결과 = ""
match 결과 {
case "win" => {
메시지 = 메시지추가(메시지, "야생 {야생이름}을(를) 쓰러뜨렸습니다!")
let 얻은 = creature.획득경험(야생레벨)
메시지 = 메시지추가(메시지, "{플레이어이름}은(는) {얻은}의 경험치를 얻었습니다.")
let 성장 = 경험적용(새플레이어, 얻은, 데모)
새플레이어 = 성장.전투원
if 성장.오른레벨 > 0 {
메시지 = 메시지추가(메시지, "{플레이어이름}의 레벨이 올랐습니다! (Lv {새플레이어.레벨})")
}
// ── P5 진화 발사: 진화 준비(드리프트>=임계)였던 크리처가 이 승리로 레벨업하면 분기 진화가 일어난다(클래식 순간). ──
// 준비 판정은 승리 전 드리프트(플레이어.드리프트)로 한다 — "준비 상태에서 다음 레벨업에 발사"(P4 힌트가 떠 있던 상태). 발사면 다음단계 "evolve" 로.
// P5 evolution trigger: if a creature that WAS ready (pre-win drift past the threshold) leveled up on this win, it branch-evolves
// (the classic moment). Readiness uses the PRE-win drift (the state where the P4 hint showed). On trigger, route to 다음단계 "evolve".
if 진화발사(플레이어.드리프트, 성장.오른레벨) {
메시지 = 메시지추가(메시지, "{플레이어이름}의 상태가 이상합니다...!")
다음단계 = "evolve"
} else {
다음단계 = "field"
}
최종결과 = "win"
}
case "lose" => {
메시지 = 메시지추가(메시지, "{플레이어이름}이(가) 쓰러졌습니다...")
메시지 = 메시지추가(메시지, "눈앞이 캄캄해졌습니다.")
다음단계 = "field"
최종결과 = "lose"
}
case "" => ()
case "fled" => ()
case "caught" => ()
}
{
배틀: 배틀{
단계: "message",
메뉴커서: 0,
전투카운터: 카운터,
적체력: 적체력,
메시지: 메시지,
메시지커서: 0,
다음단계: 다음단계,
결과: 최종결과
},
플레이어: 새플레이어
}
}
// ===== core/capture.tpz =====
// 포획 판정(순수) — P3의 심장. 스포어볼을 던져 야생을 잡을지 결정론적으로 계산한다.
// 성공률은 (적 HP 비율 ↓ → 성공 ↑) + (적 레벨 ↑ → 성공 ↓) + (볼 등급 ↑ → 성공 ↑) 로 정수 산술만 쓴다.
// 무작위 한 번은 PRNG의 CAPTURE 도메인에서 STATE에 스레딩된 포획카운터로 뽑는다 → 던질 때마다 카운터 +1.
// 같은 (시드+입력) 이면 같은 잡힘/빠져나감. 배틀/조우/돌연변이 스트림은 절대 흔들지 않는다(도메인 분리).
// Capture roll (pure) — the heart of P3. Throwing a spore-ball deterministically decides if a wild is caught.
// Success scales as (enemy HP ratio ↓ -> success ↑) + (enemy level ↑ -> success ↓) + (ball grade ↑ -> success ↑),
// integer math only. The single random draw uses the PRNG's CAPTURE domain on a capture counter threaded in STATE
// (counter +1 per throw). Same (seed + inputs) -> same caught/broke-free. NEVER perturbs the battle/encounter/mutation streams.
//
// 하우스룰(core): export는 타입/함수/불변 최상위만. 최상위 가변·print·input·host(now_millis·파일IO·미시드난수) 금지.
// deps는 안으로만(core->rng+util). 데이터·배틀·상태를 import하지 않는다 — capture는 작은 잎 모듈이다.
// House rule (core): export only types/functions/immutable bindings; no module-level mutable/print/input/host; deps inward (core->rng+util).
//
// ── 포획 성공률 공식(부동소수 없음) / capture success formula (NO floats) ──────────────────────────────
// 체력항(0..100): 적이 약할수록 큰 값. = 100 - (현재체력*100 / 최대체력). 풀피면 0, 빈사면 100에 가깝다.
// HP term (0..100): higher when the enemy is weaker. = 100 - currentHP*100/maxHP. Full HP -> 0, near-faint -> ~100.
// 레벨벌점(0..): 적 레벨이 높을수록 빼는 값. = 적레벨 (Lv 1당 1점). 고레벨 야생은 잡기 어렵다.
// Level penalty: subtract the enemy level (1 pt per level). High-level wilds are harder.
// 볼보너스: 볼 등급(0=기본볼)당 더하는 값. = 볼등급 * 20. 더 좋은 볼일수록 잘 잡힌다(P3은 기본볼 1종이라 등급 0).
// Ball bonus: add ballGrade*20. Better balls catch better (P3 ships one ball -> grade 0).
// 기본보정: +15 (빈사 풀피 양 끝에서도 적당한 곡선이 되도록 약간 들어올린다). / a small +15 floor so even a fresh hit isn't hopeless.
// 성공임계 = clamp( 체력항 + 볼보너스 + 기본보정 - 레벨벌점 , 1, 99 ) ← [1,99] 로 가둠(항상 잡힐 여지·항상 도망 여지).
// successThreshold = clamp(hpTerm + ballBonus + base - levelPenalty, 1, 99).
// 굴림(0..99) < 성공임계 → 잡힘. 굴림은 CAPTURE 도메인·엔티티=야생종아이디·포획카운터. / a 0..99 roll < threshold catches.
//
// 풀피(비율 1.0, Lv 2) → 체력항 0 + 0 + 15 - 2 = 13 → 13% (드물게 잡힘) / full HP rarely catches
// 빈사(비율 0.05, Lv 2) → 체력항 95 + 0 + 15 - 2 = 99(clamp) → 거의 항상 잡힘 / near-faint almost always catches
// 중간 한계: 체력항≤100, 볼보너스(등급≤9)≤180, +15 → 합 ≤295 ≪ 9.22e18 (오버플로 불가). currentHP*100 ≤ (maxHP)*100, maxHP≤ baseHP+99*2 작음.
//
// Key identifiers: 포획결과=CaptureResult, 포획성공임계=captureThreshold, 포획판정=rollCapture, 기본볼등급=defaultBallGrade.
import core.rng { 난수범위 }
import core.util { 범위가두기, 하한가두기 }
// 포획 판정 결과: 잡혔는지 + 그 던짐에 쓰인 성공임계(디버그/트레이스용) + 진행된 포획카운터.
// Capture result: caught? + the success threshold used (for traces/debug) + the advanced capture counter.
export type 포획결과 = { 잡힘: bool, 성공임계: int, 새카운터: int }
// P3이 가진 유일한 볼(스포어볼)의 등급. 0 = 기본볼. 등급 시스템은 후속(여러 볼)에 대비해 인자로 둔다.
// The grade of P3's only ball (spore-ball). 0 = basic. Kept as a parameter so multi-ball grades can come later.
export function 기본볼등급() -> int {
0
}
// 포획 성공임계를 정수 산술로 계산한다(부동소수 없음). [1, 99] 로 가둔다 — 풀피도 약간의 여지, 빈사도 약간의 탈출 여지.
// Compute the capture success threshold with integer math only. Clamped to [1, 99] (full HP still has a sliver, near-faint a tiny escape).
// 인자: 적 현재체력·적 최대체력·적 레벨·볼 등급. / args: enemy currentHP, enemy maxHP, enemy level, ball grade.
export function 포획성공임계(적현재체력: int, 적최대체력: int, 적레벨: int, 볼등급: int) -> int {
// 0 나눗셈 방어. / Guard against a zero maxHP.
let 안전최대 = 하한가두기(적최대체력, 1)
// 현재체력을 [0, 최대] 로 가둔다(조작된 STATE 방어). / Clamp currentHP into [0, maxHP] (defends crafted STATE).
let 현재 = 범위가두기(적현재체력, 0, 안전최대)
// 체력항(0..100): 적이 약할수록 크다. / HP term: larger when the enemy is weaker.
let 체력항 = 100 - (현재 * 100 / 안전최대)
// 레벨벌점: 음수 레벨 방어(최소 0). / Level penalty, guarding against a negative crafted level.
let 레벨 = 하한가두기(적레벨, 0)
// 볼보너스: 음수 등급 방어(최소 0). / Ball bonus, guarding a negative grade.
let 등급 = 하한가두기(볼등급, 0)
범위가두기(체력항 + 등급 * 20 + 15 - 레벨, 1, 99)
}
// 포획 판정: 성공임계를 계산하고 CAPTURE 도메인에서 한 번 굴린다. 굴림 < 임계 이면 잡힘. 카운터는 사용 후 +1.
// Roll a capture: compute the threshold, draw once from the CAPTURE domain. roll < threshold -> caught. Counter +1.
// 도메인 "capture", 엔티티 = 야생종아이디(종마다 스트림 분리), 카운터 = STATE의 포획카운터(던질 때마다 진행).
// Domain "capture", entity = wild species id (per-species stream), counter = STATE's capture counter (advances per throw).
export function 포획판정(시드: string, 카운터: int, 야생종아이디: int, 적현재체력: int, 적최대체력: int, 적레벨: int, 볼등급: int) -> 포획결과 {
let 임계 = 포획성공임계(적현재체력, 적최대체력, 적레벨, 볼등급)
let 굴림 = 난수범위("capture", 야생종아이디, 카운터, 시드, 0, 99)
{ 잡힘: (굴림 < 임계), 성공임계: 임계, 새카운터: 카운터 + 1 }
}
// ===== core/genome.tpz =====
// 게놈 + 드리프트(P4) — 시드 결정론 분기진화 트위스트의 등뼈. 코어의 잎(rng 만 의존, 순수).
// 각 크리처는 ① 계통시드(문자열)에서 결정론적으로 유도된 원본 게놈(작은 int 벡터)과 ② 성장에 따라 천천히 쌓이는 드리프트
// 카운터를 갖는다. 드리프트가 쌓이면 표현형(보이는 게놈)이 미세하게 흔들리고, 임계에 닿으면 "진화 준비" 플래그가 켜진다.
// 같은 월드시드 + 같은 플레이 = 같은 게놈 + 같은 드리프트 → (P5에서) 같은 분기 진화. 그것이 트위스트의 핵심이다.
// Genome + drift (P4) — the backbone of the seed-deterministic branching-evolution twist. A core leaf (depends only on rng; pure).
// Each creature carries ① an ORIGINAL genome (a small int vector) derived deterministically from its LINEAGE SEED (a string),
// and ② a DRIFT counter that accrues slowly as it grows. As drift rises, the phenotype (visible genome) nudges subtly; at the
// threshold the "ready to branch-evolve" flag flips. Same world-seed + same play = same genome + same drift -> (in P5) the same
// branch evolution. That reproducibility IS the twist.
//
// 하우스룰(core): export는 타입/함수/불변 최상위만. 최상위 가변·print·input·host(now_millis·파일IO·미시드난수) 금지.
// deps는 안으로만(core->rng+util). 데이터·상태·배틀을 import 하지 않는다 — genome 은 작은 잎 모듈이다.
// House rule (core): export only types/functions/immutable bindings; no module-level mutable/print/input/host; deps inward (core->rng+util).
//
// ── 결정론 (필수, 트위스트의 사활) ──────────────────────────────────────────────────────────────────
// 게놈은 계통시드의 순수 함수다: 트레이트[i] = (난수("mutation", 엔티티, i, 계통시드) 의 상위비트) % 강도상한.
// 엔티티 = 계통시드 문자열의 안정 해시(스트림을 크리처마다 분리). 카운터 = 트레이트 인덱스(0..4). 도메인 = "mutation".
// "mutation" 도메인은 조우/배틀/포획 스트림과 완전히 분리된다(같은 카운터라도 도메인이 달라 독립). → 배틀 난수가 게놈을 흔들 수 없다.
// The genome is a pure function of the lineage seed: trait[i] = (high bits of rand("mutation", entity, i, lineageSeed)) % span.
// entity = a stable hash of the lineage-seed string (separates each creature's stream); counter = the trait index (0..4);
// domain = "mutation", fully isolated from the encounter/battle/capture streams (different domain => independent even at the
// same counter) — so battle rolls can NEVER perturb the genome.
//
// ── 오버플로 안전(체크드 정수) ──────────────────────────────────────────────────────────────────────
// rng.난수 는 [0, 2^31) 를 돌려준다. 우리는 그걸 / 21474836 (≈2^31/100) 으로 상위비트를 떼고 % 강도상한(8) 으로 줄인다.
// 모든 값이 [0, 7] 안에 머문다. 드리프트 너지도 작은 폭(±2)이라 표현형은 [0, 9] 정도. 어떤 곱·합도 9.22e18 근처에 못 간다.
// rng.난수 returns [0, 2^31). We take the high bits (/ 21474836 ≈ 2^31/100) then % span(8); every trait stays in [0,7].
// Drift nudges are tiny (±2), so the phenotype stays ~[0,9]. No product/sum gets anywhere near the checked-int bound.
//
// Key identifiers: 게놈=Genome, 트레이트수=TRAIT_COUNT, 강도상한=TRAIT_SPAN, 진화임계=READY_THRESHOLD,
// 계통엔티티=lineageEntity, 트레이트유도=deriveTrait, 게놈유도=deriveGenome, 표현형=phenotype,
// 드리프트너지=driftNudge, 진화준비=isReady, 게놈요약=genomeSummary, 드리프트가두기=clampDrift.
import core.rng { 난수 }
import core.util { 범위가두기, 상한가두기 }
// 게놈 = 원시 미생물 생물학의 작은 int 벡터(전부 오리지널 트레이트, IP 안전). 각 값은 [0, 강도상한).
// 색소=pigment(빛/색)·막=membrane(세포막 두께)·크기=size(몸집)·대사=metabolism(대사 속도)·촉수=cilia(섬모/촉수 수).
// Genome = a small int vector of ORIGINAL microbe biology (all original traits; IP-safe). Each value is in [0, span).
// pigment / membrane / size / metabolism / cilia. (No Nintendo/Pokemon concept anywhere — these are pond-microbe traits.)
export type 게놈 = {
색소: int,
막: int,
크기: int,
대사: int,
촉수: int
}
// 트레이트 개수(5). 인덱스 0..4 가 색소/막/크기/대사/촉수. / Number of traits (5); indices 0..4 map to the fields above.
export let 트레이트수 = 5
// 각 트레이트의 강도 상한(배타적). 값은 [0, 8) = 0..7. 작은 정수라 표현·오버플로 모두 안전. / Per-trait span (exclusive): values 0..7.
export let 강도상한 = 8
// "진화 준비" 임계. 드리프트가 이 값에 닿으면 ready 플래그가 켜진다. 10: 유한한 야생(연못+심층 ~12마리)을 다 비우면 ~3-4세대까지 진화 → 키운 게놈이 보스를 압도(자동 영화가 정복까지 도달).
// "Ready to evolve" threshold. 10: clearing the finite wilds (~12) reaches ~gen 3-4, so the cultivated genome overpowers the boss and the auto-movie reaches conquest.
export let 진화임계 = 10
// 드리프트 안전 상한. 손상/조작된 STATE의 거대 드리프트가 산술을 넘기지 못하게 가둔다(역직렬화 방어선).
// Drift hard cap so a corrupt/crafted STATE's huge drift cannot overflow the arithmetic (a deserialization guardrail).
export let 드리프트상한 = 1000000
// 표현형 너지의 단계 상한. 너지 루프가 단계에 비례하므로(O(stage)), 단계를 상수로 가둬 조작된 거대 드리프트가 렌더를 느리게 못 하게 한다.
// 진화임계/8 = 3 까지가 의미 있는 미리보기 범위(준비에서 멈춘다). 그 너머는 P5의 진화가 가져가므로 표현형은 최대 단계에서 정지한다.
// Phenotype nudge stage cap. The nudge loop is O(stage), so we cap the stage to a CONSTANT — a crafted huge drift cannot slow the
// render to a crawl. The meaningful preview range is up to 진화임계/8 = 3 (it stops at ready); beyond that P5's evolution takes over.
export let 단계상한 = 3
// ── 계통시드 빌더 / lineage-seed builders ──────────────────────────────────────────────────────────
// 계통시드 = (월드시드 + 크리처의 기원)에서 만든 문자열. 같은 플레이는 같은 기원 → 같은 계통시드 → 같은 게놈(재현).
// 다른 월드시드는 다른 계통시드 → 다른 게놈. 시작종은 "starter", 잡은 야생은 그 포획 스텝/위치/종으로 기원을 박는다.
// Lineage seed = a string from (world_seed + the creature's ORIGIN). Same play -> same origin -> same lineage seed -> same
// genome (reproducible). A different world-seed -> a different lineage seed -> a different genome. The starter origins from
// "starter"; a caught wild origins from its catch step/position/species.
// 시작종(스타터)의 계통시드. / The starter's lineage seed.
export function 시작계통시드(월드시드: string) -> string {
"{월드시드}#starter"
}
// 잡은 야생의 계통시드: 포획 스텝·위치·종으로 기원을 고정한다. 같은 시드+같은 스텝/칸/종 = 같은 계통시드(재현).
// A caught wild's lineage seed: pin the origin to the catch step/position/species. Same seed + same step/cell/species = same lineage seed.
export function 야생계통시드(월드시드: string, 스텝: int, 엑스: int, 와이: int, 종아이디: int) -> string {
"{월드시드}#wild@{스텝}:{엑스}:{와이}:{종아이디}"
}
// 계통시드에서 "출처"(이 계통이 어디서 왔는가) 라벨을 유도한다. 시조 = 시작종, "야생 #<스텝>" = 그 스텝에 잡은 야생.
// 진화 시드는 부모 시드에 ">분기"를 덧붙이므로(새계통시드), 원점 마커(#starter·#wild@)는 어떤 노드 시드에도 앞에 남아 있다 → 어느 노드든 출처를 안다.
// Derive a "provenance" label from a lineage seed (where this lineage came from). The origin marker survives at the front of every node's seed.
export function 출처(계통시드: string) -> string {
let 해시조각 = 계통시드.split("#")
if 해시조각.length < 2 { return "야생" }
let 핵심 = 해시조각[1].split(">")[0] // ">분기" 진화 접미 제거 → 원점만 / drop the ">branch" evolution suffixes, keep the origin
if 핵심 == "starter" { return "시조" }
let 와일드 = 핵심.split("@")
if 와일드.length >= 2 {
let 메타 = 와일드[1].split(":") // [스텝, 엑스, 와이, 종] / [step, x, y, species]
if 메타.length >= 1 { return "야생 #{메타[0]}" }
}
"야생"
}
// 출처의 짧은 코드 키(숲 공유 코드용). 시조 = "S", 야생 = "W<스텝>". 같은 시드+같은 포획이면 같은 키(비교 가능). / compact origin KEY for the forest code: "S" / "W<step>".
export function 출처키(계통시드: string) -> string {
let 해시조각 = 계통시드.split("#")
if 해시조각.length < 2 { return "W" }
let 핵심 = 해시조각[1].split(">")[0]
if 핵심 == "starter" { return "S" }
let 와일드 = 핵심.split("@")
if 와일드.length >= 2 {
let 메타 = 와일드[1].split(":")
if 메타.length >= 1 { return "W{메타[0]}" }
}
"W"
}
// ── 게놈 유도(계통시드의 순수 함수) / genome derivation (a pure function of the lineage seed) ──────────
// 계통시드 문자열을 mutation 도메인의 안정 엔티티 정수로 바꾼다(크리처마다 게놈 스트림을 분리). 도메인 "mutation",
// 카운터 0 으로 한 번 뽑아 [0, 2^31) 정수를 얻는다 — 이 값이 크리처의 고유 mutation 엔티티가 된다.
// Turn the lineage-seed string into a stable mutation-domain entity int (separates each creature's genome stream).
// Draw once in the "mutation" domain at counter 0 to get a [0,2^31) int — that becomes the creature's unique mutation entity.
export function 계통엔티티(계통시드: string) -> int {
// 엔티티 인자는 0(아직 엔티티가 없으므로). 시드 문자열 자체가 스트림을 가른다. / entity arg 0 (none yet); the seed string itself separates streams.
난수("mutation", 0, 0, 계통시드)
}
// 트레이트 하나를 유도한다: mutation 도메인·엔티티·카운터=트레이트인덱스 로 한 번 뽑아 상위비트를 떼고 % 강도상한.
// 상위비트(/ 21474836)를 쓰는 이유는 rng 와 동일: LCG 저차비트는 규칙적이라 작은 폭으로 줄이면 편향된다 — 상위비트가 잘 퍼진다.
// Derive one trait: draw once in "mutation" at (entity, counter=traitIndex), take the high bits, then % span.
// We use the HIGH bits (/ 21474836) for the same reason as rng: the LCG's low bits are regular and would bias a small reduction.
function 트레이트유도(엔티티: int, 트레이트인덱스: int, 계통시드: string) -> int {
let 값 = 난수("mutation", 엔티티, 트레이트인덱스, 계통시드)
(값 / 21474836) % 강도상한
}
// 원본 게놈을 계통시드에서 결정론적으로 만든다. 다섯 트레이트 = 다섯 카운터(0..4). 같은 계통시드 → 항상 같은 게놈.
// Build the ORIGINAL genome deterministically from the lineage seed. Five traits = five counters (0..4). Same seed -> same genome.
export function 게놈유도(계통시드: string) -> 게놈 {
let 엔티티 = 계통엔티티(계통시드)
{
색소: 트레이트유도(엔티티, 0, 계통시드),
막: 트레이트유도(엔티티, 1, 계통시드),
크기: 트레이트유도(엔티티, 2, 계통시드),
대사: 트레이트유도(엔티티, 3, 계통시드),
촉수: 트레이트유도(엔티티, 4, 계통시드)
}
}
// ── 드리프트 / drift ────────────────────────────────────────────────────────────────────────────────
// 드리프트는 크리처가 "성장"할 때마다 천천히 쌓이는 카운터다. 레벨업 1회 = +2, 배틀 승리 1회 = +1(레벨업 없이 이긴 경우).
// 카운터일 뿐이라 게임 진실은 순수 함수로 유지된다(같은 시드+입력 → 같은 드리프트). 표현형 너지는 이 카운터에서 결정론적으로 파생된다.
// Drift is a counter that accrues SLOWLY each time a creature "grows". A level-up adds +2; a win without a level-up adds +1.
// It's just a counter, so the game stays a pure function (same seed+inputs -> same drift). Phenotype nudges derive from it.
// 한 번의 성장(레벨업 수·승리 여부)으로 더해질 드리프트 증가량. 레벨업이 있으면 레벨당 +2, 없으면 단순 승리 +1.
// Drift gained from one growth event (levels gained, did-win). Each level gained adds +2; a win with no level-up adds +1.
// 느림의 핵심: 레벨업이 흔치 않고(EXP 곡선) 한 판에 +2~+4 정도라 임계 24 는 보통 8~12판이 걸린다. / Slow by design.
//
// 데모 모드(opt-in): 데모=true 이면 드리프트가 훨씬 빨리 쌓여 ~2승이면 임계(24)에 닿는다 — 소유자·심사위원이 진화 클라이맥스를
// 약 2분 안에 본다(재미 게이트 평가용). 데모=false 면 위의 느린 브랜드 값 그대로(레벨당 +2 / 승리 +1) — 기본 동작은 바이트 동일.
// 데모는 입력(SHELL)의 일부라 데모-온/데모-오프 각각 결정론적이다(시드+입력+플래그가 같으면 재현). 데모 값은 기본 느림을 바꾸지 않는다.
// Demo mode (opt-in): when 데모=true drift accrues MUCH faster so ~2 wins reach the threshold (24) — the owner/judges see the
// evolution climax in ~2 min (to evaluate the fun gate). When 데모=false the slow brand values above are byte-identical to before
// (+2/level, +1/win). The flag is part of the input (SHELL), so each mode is deterministic; demo never changes the default slow numbers.
export function 드리프트증가(오른레벨: int, 승리: bool, 데모: bool) -> int {
// 유한한 야생(연못+심층 ~10마리, 그중 몇은 잡고 나머지를 쓰러뜨림 ~8회)으로 보스를 이길 만큼(~3-4세대) 진화하려면 한 승리의 드리프트가 넉넉해야 한다. 레벨업당 +3, 레벨업 없는 승리도 +2.
// To reach ~gen 3-4 (boss-ready) from the ~8 defeats the finite wilds afford, each win must yield enough drift: +3 per level gained, +2 for a no-level win.
let mut 증가 = 0
if 오른레벨 > 0 {
증가 = 오른레벨 * 3
} else if 승리 {
증가 = 2
}
// 데모: 레벨업 승리는 레벨당 +14(한 판이면 보통 1레벨업 → +14, 두 판 → 28 ≥ 24 진화), 레벨업 없는 승리도 +12 로 둬 어떤 승리든 2승이면 임계 돌파.
// Demo: a level-up win gives +14 per level (a typical 1-level win -> +14; two wins -> 28 >= 24 evolve), a no-level win gives +12
// so ANY two wins cross the threshold. Default (데모=false) is untouched.
if 데모 {
if 오른레벨 > 0 {
증가 = 오른레벨 * 14
} else if 승리 {
증가 = 12
}
}
증가
}
// 드리프트를 [0, 드리프트상한] 으로 가둔다. 조작된 거대 드리프트가 너지 산술/직렬화를 넘기지 못하게 한다(역직렬화·증가 양쪽에서 호출).
// Clamp drift into [0, cap] so a crafted huge drift cannot overflow the nudge math / serialization (called on restore AND on increment).
export function 드리프트가두기(드리프트: int) -> int {
범위가두기(드리프트, 0, 드리프트상한)
}
// 드리프트 단계(0..드리프트임계상한): 미세 표현형 너지에 쓰는 "느린 시계". 드리프트 / 8 → 임계(24)까지 0,1,2,3 의 4단계.
// 단계가 오를 때만 표현형이 한 칸 흔들린다 → 너지가 드물고 느리게 보인다(브랜드: slow.monster). 8 미만은 단계 0(원본 그대로).
// Drift stage (0..): the "slow clock" for the phenotype nudge. drift/8 -> stages 0,1,2,3 up to the threshold(24). The phenotype
// only nudges when the STAGE rises, so nudges look rare and slow (the brand: slow.monster). Below 8 -> stage 0 (original genome).
function 드리프트단계(드리프트: int) -> int {
상한가두기(드리프트가두기(드리프트) / 8, 단계상한) // 단계 상한으로 가둔다 — 너지 루프를 상수 시간으로 묶는다(조작된 거대 드리프트 방어). / cap the stage (bounds the O(stage) nudge loop).
}
// 트레이트 하나의 드리프트 너지: 드리프트 단계만큼, mutation 도메인에서 결정론적으로 -1/0/+1 방향을 뽑아 누적한다.
// 단계 s 에서 너지 = Σ_{k=1..s} dir(k). dir 는 (계통시드+트레이트+단계)로 결정론적(같은 시드+같은 성장 → 같은 너지).
// One trait's drift nudge: by the drift stage, deterministically draw a -1/0/+1 direction per stage in the "mutation" domain and
// accumulate. At stage s, nudge = Σ_{k=1..s} dir(k). dir is deterministic in (lineage seed + trait + stage) -> reproducible.
// 카운터는 트레이트인덱스 + 단계*트레이트수 로 어긋내 게놈유도(카운터 0..4)와 충돌하지 않는다. / counter offset avoids colliding with genome counters.
function 너지(엔티티: int, 트레이트인덱스: int, 단계: int, 계통시드: string) -> int {
let mut 합 = 0
let mut k = 1
while k <= 단계 {
// dir ∈ {-1,0,+1}: mutation 도메인에서 (값/21474836)%3 - 1. 카운터를 단계마다 띄워 단계별로 독립 방향. / per-stage independent direction.
let 카운터 = 트레이트인덱스 + k * 트레이트수
let 값 = 난수("mutation", 엔티티, 카운터, 계통시드)
let 방향 = ((값 / 21474836) % 3) - 1
합 = 합 + 방향
k = k + 1
}
합
}
// 표현형(보이는 게놈) = 원본 게놈 + 드리프트 너지. 너지는 작아서(단계≤3 → 너지 ∈ [-3,+3]) 표현형은 대략 [-3, 10].
// JS 가 표현형으로 블롭의 색조/모양을 미세하게 흔든다(느린 진화의 미리보기). 값은 작아 오버플로 불가.
// Phenotype (visible genome) = original genome + drift nudge. Nudges are tiny (stage<=3 -> nudge in [-3,+3]) so phenotype ~[-3,10].
// JS uses the phenotype to subtly shift the blob's tint/shape (the preview of the coming mutation). Values stay small -> no overflow.
export function 표현형(원본: 게놈, 계통시드: string, 드리프트: int) -> 게놈 {
let 단계 = 드리프트단계(드리프트)
if 단계 <= 0 { return 원본 } // 단계 0: 원본 그대로(아직 너지 없음) / stage 0: unchanged
let 엔티티 = 계통엔티티(계통시드)
원본{
색소: 원본.색소 + 너지(엔티티, 0, 단계, 계통시드),
막: 원본.막 + 너지(엔티티, 1, 단계, 계통시드),
크기: 원본.크기 + 너지(엔티티, 2, 단계, 계통시드),
대사: 원본.대사 + 너지(엔티티, 3, 단계, 계통시드),
촉수: 원본.촉수 + 너지(엔티티, 4, 단계, 계통시드)
}
}
// "진화 준비" 판정: 드리프트가 임계에 닿았는가. P5 가 이 플래그를 소비해 분기 진화를 제안/고정한다(P4는 플래그만 켠다).
// "Ready to evolve": has drift reached the threshold? P5 consumes this flag to offer/lock a branch (P4 only sets it).
export function 진화준비(드리프트: int) -> bool {
드리프트가두기(드리프트) >= 진화임계
}
// 드리프트 진행도(0..100 퍼센트): UI 의 드리프트 미터용. 임계 대비 비율을 정수 산술로 [0,100] 으로 만든다(부동소수 없음).
// Drift progress as a 0..100 percent for the UI meter. Ratio vs. the threshold, integer math, clamped to [0,100] (no floats).
export function 드리프트퍼센트(드리프트: int) -> int {
let d = 드리프트가두기(드리프트)
범위가두기(d * 100 / 진화임계, 0, 100) // 진화임계*100 ≤ 2400, d ≤ 1e6 → d*100 ≤ 1e8 ≪ 9.22e18 (안전) / safe
}
// 게놈을 컴팩트한 한 줄 요약 문자열로(드로우리스트/디버그용). "색소·막·크기·대사·촉수" 다섯 값. P6 의 계통도감 라벨에도 재사용 예정.
// Compact one-line genome summary (for the draw-list / debug): the five values pigment·membrane·size·metabolism·cilia. Reused by P6's dex.
export function 게놈요약(지: 게놈) -> string {
"{지.색소},{지.막},{지.크기},{지.대사},{지.촉수}"
}
// CSV 한 칸을 트레이트 값 [0, 강도상한-1] 로 파싱·가둔다(부족/조작 방어). / parse one CSV cell into a clamped trait value.
function 트레이트칸(부분: Array<string>, 인덱스: int) -> int {
범위가두기(if 부분.length > 인덱스 { toInt(부분[인덱스]) ?? 0 } else { 0 }, 0, 강도상한 - 1)
}
// "색소,막,크기,대사,촉수" CSV 를 게놈으로 복원한다(게놈요약의 역). 도감의 "가지 않은 길"(형제 후보) 재유도에 쓴다.
// Parse a genome-summary CSV back into a genome (the inverse of 게놈요약). Used by the dex "roads not taken" (sibling) re-derivation.
export function 게놈요약파싱(요약: string) -> 게놈 {
let 부분 = 요약.split(",")
{ 색소: 트레이트칸(부분, 0), 막: 트레이트칸(부분, 1), 크기: 트레이트칸(부분, 2), 대사: 트레이트칸(부분, 3), 촉수: 트레이트칸(부분, 4) }
}
// ===== core/evolution.tpz =====
// 분기 진화 LOCK(P5) — 프로젝트의 트위스트(생명선)의 심장. 시드 결정론·분기형 돌연변이 계통.
// 준비된(드리프트 임계) 크리처가 배틀에서 레벨업하면 "분기 진화"한다(클래식 순간). (부모 게놈 + 계통시드 + 진화카운터)에서
// 2~3개의 서로 다른 후보 돌연변이를 결정론적으로 유도하고, 플레이어가 한 분기를 골라 LOCK 하면 그 형태가 영구 고정된다.
// 같은 월드시드 + 같은 플레이(같은 분기 선택) = 같은 생명의 나무(같은 분기·같은 고정 형태). 다른 시드 = 다른 나무.
// Branch-evolution LOCK (P5) — the heart of the project's twist (its lifeline): seed-deterministic, BRANCHING mutation.
// When a READY creature (drift past threshold) levels up in a battle, it branch-evolves (the classic moment). From
// (parent genome + lineage seed + evolution counter) we DERIVE 2-3 DISTINCT candidate mutations; the player picks one
// and LOCKS it -> that form is permanent. Same world-seed + same play (same branch picked) = the SAME tree of life
// (the same branch, the same locked form). A different seed -> a different tree.
//
// 하우스룰(core): export는 타입/함수/불변 최상위만. 최상위 가변·print·input·host(now_millis·파일IO·비트연산·미시드난수) 금지.
// deps는 안으로만(core->rng, core->genome, core->creature, core->util). / House rule (core): pure; deps inward (rng/genome/creature/util).
//
// ── 결정론 (필수, 트위스트의 사활) ──────────────────────────────────────────────────────────────────
// 모든 진화 무작위는 PRNG의 "mutation" 도메인에서, 크리처별 "진화카운터"(STATE에 저장, 진화 1회마다 +1)로 뽑는다.
// 진화 스트림은 조우/배틀/포획/드리프트 스트림과 완전히 분리된다(도메인 "mutation" + 높은 카운터 밴드).
// 카운터 밴드: 진화시작 + 진화카운터 * 카운터스텝 + 지역인덱스.
// 진화시작(1,000,000)은 게놈유도(카운터 0..4)·드리프트 너지(카운터 ≤ 4 + 3*5 = 19)와 절대 겹치지 않는다.
// All evolution randomness comes from the PRNG "mutation" domain on a per-creature EVOLUTION COUNTER (stored in STATE,
// +1 each evolution), fully isolated from the encounter/battle/capture/drift streams (domain "mutation" + a high band).
// counter band = EVO_BASE + evoCounter*EVO_STEP + localIndex. EVO_BASE(1,000,000) never collides with the genome
// counters (0..4) or the drift-nudge counters (<= 19), so the evolution stream is independent.
//
// ── 오버플로 안전(체크드 정수) ──────────────────────────────────────────────────────────────────────
// rng.난수 는 [0, 2^31) 를 돌려준다. 우리는 상위비트(/ 21474836)를 떼고 작은 폭으로 줄인다(트레이트 너지·후보수·이름 인덱스).
// 게놈 값은 [0, 강도상한)=[0,7]로 다시 가두고, 세대·진화카운터·스탯부스트도 상수 상한으로 가둔다 → 어떤 곱/합도 9.22e18 근처에 못 간다.
// rng.난수 returns [0, 2^31); we take the HIGH bits (/ 21474836) then % a small span. Genome stays in [0,7], generation /
// evo-counter / stat-boost are all capped to constants -> no product or sum gets anywhere near the checked-int bound.
//
// Key identifiers: 후보=Candidate, 진화결과=EvolveResult, 계통노드=LineageNode, 진화시작=EVO_BASE, 카운터스텝=EVO_STEP,
// 세대상한=GEN_CAP, 진화카운터상한=EVO_COUNTER_CAP, 후보수=candidateCount, 후보유도=deriveCandidate,
// 후보들유도=deriveCandidates, 형태이름=formName, 분기진화=branchEvolve, 게놈요약=genomeSummaryReused.
import core.rng { 난수 }
import core.genome // §17: 게놈 타입을 한정 참조(genome.게놈)하려면 모듈째 import. 값 함수는 한정 이름으로 부른다. / whole-module import (qualified 게놈 type + value fns).
import core.creature // §17: 전투원 타입을 한정 참조(creature.전투원). 최대체력도 creature.최대체력. / whole-module import for the qualified 전투원 type.
import core.util { 범위가두기, 하한가두기, 상한가두기 }
import data.species { 종찾기 }
// ── 결정론 카운터 밴드 / deterministic counter band ──────────────────────────────────────────────────
// 진화 무작위가 게놈(0..4)·드리프트(≤19) 카운터와 절대 겹치지 않게 1,000,000 부터 시작한다. 진화카운터마다 1,000칸 밴드.
// Evolution randomness starts at 1,000,000 so it never overlaps the genome (0..4) / drift (<=19) counters. 1,000 slots per evolution.
export let 진화시작 = 1000000
export let 카운터스텝 = 1000
// 세대 상한. 손상/조작된 STATE의 거대 세대가 산술/직렬화를 넘기지 못하게 가둔다(역직렬화·진화 양쪽에서 호출).
// Generation cap so a crafted huge generation cannot overflow the math/serialization (clamped on restore AND on evolve).
export let 세대상한 = 99
// 진화카운터 상한. 카운터 밴드(진화시작 + 진화카운터*1000)가 2^31 근처로 못 가게 가둔다(1000*200만 = 20억 < 2^31).
// Evolution-counter cap so the band (EVO_BASE + evoCounter*1000) stays well under 2^31. Also bounds the lineage chain.
export let 진화카운터상한 = 1000000
// 후보 분기 최대 개수(3). 진짜 분기를 보장하려고 최소 2개. / Max candidate branches (3); at least 2 to guarantee a real branch.
export let 최대후보수 = 3
export let 최소후보수 = 2
// ── 후보(한 분기) / a candidate (one branch) ─────────────────────────────────────────────────────────
// 후보 = 돌연변이 게놈 + 그 게놈을 따르는 파생 형태(원본 오리지널 형태이름·스탯부스트·세대+1·새 계통시드).
// 형태이름·외형은 손으로 쓴 진화 사슬이 아니라 (부모 게놈 + 계통시드 + 진화카운터)에서 DERIVE 된다(지시서: 하드코딩 금지).
// A candidate = a mutated genome + a DERIVED form (an ORIGINAL form-name, a stat boost, generation+1, a new lineage seed).
// The form-name/look are DERIVED from (parent genome + lineage seed + evolution counter), NOT hand-authored chains (per directive).
export type 후보 = {
분기: int, // 분기 인덱스(0..후보수-1) — 입력 시퀀스로 잠긴다(재현). / branch index, locked by the input sequence
형태이름: string, // 파생 원본 형태 이름(IP 안전). / a DERIVED original form-name (IP-safe)
게놈: genome.게놈, // 돌연변이 게놈(몇 트레이트가 너지됨). / the mutated genome (a few traits nudged)
스탯부스트: int, // 진화 스탯 부스트(레벨처럼 종 기준치에 더해짐, [1, 부스트상한]). / evolution stat boost
지배트레이트: int // 돌연변이 게놈에서 가장 큰 트레이트 인덱스(외형·조건 분기용). / dominant trait index of the mutated genome
}
// ── 진화 결과(LOCK) / evolution result (the LOCK) ────────────────────────────────────────────────────
// 잠긴 새 전투원(영구 형태) + 그 진화를 기록할 계통 노드 재료(형태이름·게놈요약·세대·시드지문). app 가 STATE에 끼운다.
// The locked new combatant (permanent form) + the materials for the lineage node (form name, genome summary, generation, fingerprint).
export type 진화결과 = {
전투원: creature.전투원,
형태이름: string,
분기: int,
세대: int,
게놈요약전: string, // 진화 전 게놈 요약(노드/트레이스용) / parent genome summary
게놈요약후: string, // 진화 후 게놈 요약 / child genome summary
시드지문: string // 새 계통시드(= 시드 지문). 같은 시드+같은 분기 → 같은 지문(재현 증거). / new lineage seed = seed fingerprint
}
// 계통 노드 — P6의 Lineage Dex(계통수)가 그릴 발견 기록. STATE 의 lineage_nodes 리스트에 진화마다 append 된다.
// 결정론적·시드 박힘: 같은 시드+같은 플레이 → 같은 노드 시퀀스. / A LineageNode — the discovered record P6's dex will draw.
export type 계통노드 = {
유아이디: int, // 이 형태의 안정 식별자(계통시드 해시). / stable id for this form (lineage-seed hash)
부모유아이디: int, // 진화 직전 형태의 유아이디(부모). / the parent form's uid
형태이름: string,
게놈요약: string,
세대: int,
발견스텝: int, // 이 진화가 일어난 STATE 스텝. / the STATE step at which this evolution happened
시드지문: string // 계통시드(재현·공유의 지문). / the lineage seed (the reproducibility/share fingerprint)
}
// ── 진화 무작위 한 번 / one evolution-domain draw ────────────────────────────────────────────────────
// (엔티티, 지역인덱스, 계통시드, 진화카운터) → [0, 폭) 균일값. 상위비트(/ 21474836)를 떼 LCG 저차 편향을 피한다(rng 와 동일).
// 카운터 = 진화시작 + 진화카운터*카운터스텝 + 지역인덱스 → 다른 진화/다른 지역인덱스마다 독립 스트림.
// (entity, localIndex, lineageSeed, evoCounter) -> a [0, span) value from the HIGH bits (avoids LCG low-bit bias, as rng does).
// counter = EVO_BASE + evoCounter*EVO_STEP + localIndex -> an independent stream per evolution / per local index.
function 진화굴림(엔티티: int, 지역인덱스: int, 계통시드: string, 진화카운터: int, 폭: int) -> int {
let 카운터 = 범위가두기(진화카운터, 0, 진화카운터상한)
let 밴드 = 진화시작 + 카운터 * 카운터스텝 + 지역인덱스
let 값 = 난수("mutation", 엔티티, 밴드, 계통시드)
let p = 하한가두기(폭, 1)
(값 / 21474836) % p
}
// 후보 개수: 2 또는 3(결정론). 진화굴림으로 0/1 을 뽑아 최소후보수(2)에 더한다 → 진짜 분기 폭(2~3)을 시드가 정한다.
// Candidate count: 2 or 3 (deterministic). Draw 0/1 and add to 최소후보수(2) -> the seed decides the branch breadth (2..3).
export function 후보수(엔티티: int, 계통시드: string, 진화카운터: int) -> int {
let 추가 = 진화굴림(엔티티, 0, 계통시드, 진화카운터, 최대후보수 - 최소후보수 + 1) // 0..1
최소후보수 + 추가
}
// 트레이트 하나를 [0, 강도상한) 으로 가둔다(돌연변이 너지가 범위를 벗어나지 않게). / Clamp one trait into [0, 강도상한).
function 트레이트가두기(값: int) -> int {
범위가두기(값, 0, genome.강도상한 - 1)
}
// 게놈의 지배(최대) 트레이트 인덱스(0..4). 외형·조건 분기·이름 어간 선택에 쓴다. 동률은 낮은 인덱스. / Dominant (max) trait index.
export function 지배트레이트(지: genome.게놈) -> int {
let mut 최대값 = 지.색소
let mut 인덱스 = 0
if 지.막 > 최대값 { 최대값 = 지.막; 인덱스 = 1 }
if 지.크기 > 최대값 { 최대값 = 지.크기; 인덱스 = 2 }
if 지.대사 > 최대값 { 최대값 = 지.대사; 인덱스 = 3 }
if 지.촉수 > 최대값 { 최대값 = 지.촉수; 인덱스 = 4 }
인덱스
}
// 트레이트를 세기만큼 움직인다 — 위로 여유가 있으면 위로(성장처럼), 천장(상한)이면 아래로. 세기>=1 이라 값은 반드시 변한다(no-op 방지).
// Move a trait by 세기: up if there is headroom (reads as growth), else down off the ceiling. 세기>=1 so the value ALWAYS changes (no no-op).
function 너지(값: int, 세기: int) -> int {
// 진화는 항상 성장 — 형질을 위로만 올리고 상한(강도상한-1)에서 멈춘다. (예전엔 상한을 넘으면 감소해, "불어나는"·"단단해지는" 힌트와 게놈이 어긋났다.)
// Evolution always grows: push the trait UP and clamp at the cap. (It used to shrink on overflow, contradicting the "grows/hardens" hints.)
상한가두기(값 + 세기, genome.강도상한 - 1)
}
// 한 후보(분기 K)의 돌연변이 게놈을 유도한다. 부모 게놈에서 시작해, 분기마다 다른 2개 트레이트를 다른 방향으로 너지한다.
// 너지 트레이트·방향·세기는 (엔티티, 분기, 진화카운터)로 결정론적 → 분기 K마다 게놈이 서로 다르다(진짜 분기). 값은 [0,7]로 가둠.
// Derive one candidate's (branch K) MUTATED genome: start from the parent genome, then nudge two (branch-specific) traits in
// (branch-specific) directions by (entity, K, evoCounter) -> each branch's genome DIFFERS (a real branch). Values clamped to [0,7].
function 돌연변이게놈(부모: genome.게놈, 엔티티: int, 분기: int, 계통시드: string, 진화카운터: int) -> genome.게놈 {
// 지역인덱스를 분기로 어긋낸다(10 + 분기*5 .. 밴드 안). 분기마다 다른 트레이트/방향 굴림. / offset local index by branch.
let 기준 = 10 + 분기 * 5
// 너지할 첫째/둘째 트레이트(0..4). 둘째는 첫째와 다르게 +1 회전. / pick two distinct traits to nudge (second rotates off the first).
// 첫째 너지 트레이트는 분기마다 다르게 고정한다((지배+1+분기) → 분기별로 서로 다른 형질을 키운다 = 진짜 분기, 게놈이 반드시 다르다).
// The primary nudged trait is fixed-distinct per branch ((dominant+1+branch)) so each branch grows a DIFFERENT trait -> genomes always differ.
let 트레이트A = (지배트레이트(부모) + 1 + 분기) % genome.트레이트수
let mut 트레이트B = 진화굴림(엔티티, 기준 + 1, 계통시드, 진화카운터, genome.트레이트수)
if 트레이트B == 트레이트A { 트레이트B = (트레이트A + 1) % genome.트레이트수 }
// 너지 방향/세기: 각각 +1..+3 (진화는 능력 상승이라 항상 양의 너지로 형질을 키운다 → 진화가 "성장"으로 보인다).
// Nudge magnitudes +1..+3 (evolution always pushes traits UP, so it reads as growth). Span 3 -> values 0..2, +1 -> 1..3.
let 세기A = 진화굴림(엔티티, 기준 + 2, 계통시드, 진화카운터, 3) + 1
let 세기B = 진화굴림(엔티티, 기준 + 3, 계통시드, 진화카운터, 3) + 1
// 부모 게놈을 복사한 뒤 두 트레이트만 너지한다(필드 인덱스 0..4 = 색소/막/크기/대사/촉수). / copy parent, nudge the two chosen traits.
let mut 색소 = 부모.색소
let mut 막 = 부모.막
let mut 크기 = 부모.크기
let mut 대사 = 부모.대사
let mut 촉수 = 부모.촉수
if 트레이트A == 0 { 색소 = 너지(색소, 세기A) } else if 트레이트A == 1 { 막 = 너지(막, 세기A) } else if 트레이트A == 2 { 크기 = 너지(크기, 세기A) } else if 트레이트A == 3 { 대사 = 너지(대사, 세기A) } else { 촉수 = 너지(촉수, 세기A) }
if 트레이트B == 0 { 색소 = 너지(색소, 세기B) } else if 트레이트B == 1 { 막 = 너지(막, 세기B) } else if 트레이트B == 2 { 크기 = 너지(크기, 세기B) } else if 트레이트B == 3 { 대사 = 너지(대사, 세기B) } else { 촉수 = 너지(촉수, 세기B) }
{
색소: 트레이트가두기(색소),
막: 트레이트가두기(막),
크기: 트레이트가두기(크기),
대사: 트레이트가두기(대사),
촉수: 트레이트가두기(촉수)
}
}
// ── 파생 형태 이름(오리지널·IP 안전, 하드코딩된 진화 사슬 아님) ───────────────────────────────────────
// 이름 = 어간(돌연변이 게놈의 지배 트레이트가 고름) + 접미(분기·진화카운터가 고름). 전부 오리지널 미생물 어휘(닌텐도/포켓몬 0).
// 같은 (지배 트레이트, 분기, 진화카운터) → 같은 이름(결정론). 어간/접미는 작은 데이터 테이블(데이터 주도, 확장 쉬움).
// Form-name = a STEM (chosen by the mutated genome's dominant trait) + a SUFFIX (chosen by branch/evoCounter). All-original
// microbe vocabulary (zero Nintendo/Pokemon). Same (dominant trait, branch, evoCounter) -> the same name (deterministic).
// 지배 트레이트별 어간(0 색소/1 막/2 크기/3 대사/4 촉수). 한국어 오리지널 미생물 어근. / Stem per dominant trait (original microbe roots).
function 어간(지배: int) -> string {
if 지배 == 0 { return "빛무리" } // 색소: 빛/색이 강함 / pigment-dominant
if 지배 == 1 { return "갑각" } // 막: 단단한 막 / membrane-dominant
if 지배 == 2 { return "거대" } // 크기: 몸집이 큼 / size-dominant
if 지배 == 3 { return "열소" } // 대사: 빠른 대사 / metabolism-dominant
"촉수" // 촉수: 섬모/촉수 많음 / cilia-dominant
}
// 분기·진화카운터로 고른 접미. 분기마다 다른 접미 → 같은 어간이라도 분기별로 이름이 다르다(진짜 분기 가시화).
// Suffix chosen by branch & evolution counter. Different branches -> different suffix, so even a shared stem reads as a distinct form.
function 접미(분기: int) -> string {
// 접미는 분기 인덱스로 고정한다 → 분기마다 반드시 다른 접미 → 같은 어간이라도 이름이 겹치지 않는다(distinct 분기 보장).
// The suffix is fixed by the branch index, so different branches always get a different suffix — names never collide even with a shared stem.
let 골라 = 분기 % 6 // 0..5 (후보는 2~3개라 항상 서로 다른 접미)
if 골라 == 0 { return "체" }
if 골라 == 1 { return "충" }
if 골라 == 2 { return "포자" }
if 골라 == 3 { return "군체" }
if 골라 == 4 { return "정령" }
"변이체"
}
// 파생 형태 이름: 어간 + 접미. 세대 표기는 붙이지 않는다(노드가 세대를 따로 들고 다님). 같은 입력 → 같은 이름.
// Derived form-name: stem + suffix. (Generation is carried by the node, not baked into the name.) Same inputs -> same name.
export function 형태이름(돌연변이: genome.게놈, 엔티티: int, 분기: int, 계통시드: string, 진화카운터: int) -> string {
let 어 = 어간(지배트레이트(돌연변이))
let 접 = 접미(분기)
"{어}{접}"
}
// ── 한 후보를 통째로 유도 / derive one full candidate ────────────────────────────────────────────────
// 분기 K → 돌연변이 게놈 + 형태이름 + 스탯부스트 + 지배트레이트. 전부 (엔티티, 분기, 계통시드, 진화카운터)의 순수 함수.
// Branch K -> mutated genome + form-name + stat boost + dominant trait. All a pure function of (entity, K, lineageSeed, evoCounter).
export function 후보유도(부모: genome.게놈, 엔티티: int, 분기: int, 계통시드: string, 진화카운터: int) -> 후보 {
let 돌연변이 = 돌연변이게놈(부모, 엔티티, 분기, 계통시드, 진화카운터)
let 이름 = 형태이름(돌연변이, 엔티티, 분기, 계통시드, 진화카운터)
// 스탯부스트: 2..5(분기·진화카운터로 결정론). 종 기준치에 레벨처럼 더해져 진화가 "강해짐"으로 느껴진다. / stat boost 2..5.
let 부스트 = 진화굴림(엔티티, 40 + 분기, 계통시드, 진화카운터, 4) + 2
{
분기: 분기,
형태이름: 이름,
게놈: 돌연변이,
스탯부스트: 부스트,
지배트레이트: 지배트레이트(돌연변이)
}
}
// 후보 리스트 전체를 유도한다(2~3개). 각각 분기 인덱스가 다르고, 게놈/이름/스탯이 서로 다르다(진짜 분기).
// Derive the full candidate list (2-3). Each has a distinct branch index and a distinct genome/name/stat (a real branch).
export function 후보들유도(부모: genome.게놈, 엔티티: int, 계통시드: string, 진화카운터: int) -> Array<후보> {
let 수 = 후보수(엔티티, 계통시드, 진화카운터)
let mut 결과: Array<후보> = []
let mut k = 0
while k < 수 {
결과.push(후보유도(부모, 엔티티, k, 계통시드, 진화카운터))
k = k + 1
}
결과
}
// 후보의 부분 힌트(다 안 보여준다 — "느림 = 관찰"). 지배 트레이트로 생물학적 경향만 귀띔한다. 정확한 형태·이름은 잠근 뒤 모핑에서 공개.
// A candidate's PARTIAL hint: hint the biology by its dominant trait only; the exact form/name unlocks at the morph (slow = observation).
export function 후보힌트(후보입력: 후보) -> string {
// 분기마다 다른 트레이트를 귀띔하도록 분기 인덱스를 섞는다(지배 + 분기) — 같은 지배 트레이트여도 분기별 힌트가 갈린다(진짜 분기 보장).
// Fold in the branch index so each branch hints a different trait (지배 + 분기); even with the same dominant trait the hints diverge (real branching).
let 트레이트 = (후보입력.지배트레이트 + 1 + 후보입력.분기) % genome.트레이트수 // 게놈의 첫째 너지 트레이트(지배+1+분기)와 일치 → 힌트가 실제 변화를 가리킨다. / match the genome's primary nudged trait so the hint is truthful.
if 트레이트 == 0 { "빛을 머금는 색소가 짙어지는 기운" }
else if 트레이트 == 1 { "막이 단단해지는 기운" }
else if 트레이트 == 2 { "몸집이 불어나는 기운" }
else if 트레이트 == 3 { "대사가 빨라지는 기운" }
else { "촉수가 뻗어 나가는 기운" }
}
// 후보 형태 이름의 첫 글자 + "…?"(확정 전까지 이름을 가린다). 빈 이름이면 "???". / first scalar of the form name + "…?" (name masked until locked).
export function 후보이름조각(후보입력: 후보) -> string {
let mut 첫 = ""
let mut 봄 = false
for 글 in 후보입력.형태이름.scalars() {
if !봄 { 첫 = 글 ; 봄 = true }
}
if 봄 { "{첫}…?" } else { "???" }
}
// 한 진화 갈림길의 "가지 않은 길" 힌트들. 부모(게놈·계통시드·진화카운터=세대)에서 후보를 재유도하고, 실제 고른 분기(게놈요약 일치)를 빼고 나머지 힌트를 모은다.
// 같은 시드면 늘 같은 형제들이 재현된다(결정론) → 도감이 "다른 선택이었다면 다른 나무"를 보여 준다. The hints of the roads NOT taken at one evolution.
export function 형제힌트들(부모게놈: genome.게놈, 부모시드: string, 부모세대: int, 선택게놈요약: string) -> string {
let 엔티티 = 계통유아이디(부모시드)
let 후보들 = 후보들유도(부모게놈, 엔티티, 부모시드, 부모세대)
let mut 결과 = ""
for 후 in 후보들 {
if genome.게놈요약(후.게놈) != 선택게놈요약 {
let 힌트 = 후보힌트(후)
결과 = if 결과 == "" { 힌트 } else { "{결과} · {힌트}" }
}
}
결과
}
// 한 진화에서 실제로 고른 분기 인덱스를 복원한다(부모에서 후보 재유도 → 자식 게놈과 일치하는 후보의 분기). 못 찾으면 -1. 계통 경로 코드용.
// Recover the branch index that was actually chosen at one evolution (re-derive candidates, match the child's genome). -1 if none. For the lineage path code.
export function 노드분기(부모게놈: genome.게놈, 부모시드: string, 부모세대: int, 자식게놈요약: string) -> int {
let 엔티티 = 계통유아이디(부모시드)
let 후보들 = 후보들유도(부모게놈, 엔티티, 부모시드, 부모세대)
let mut 분기 = -1
for 후 in 후보들 {
if genome.게놈요약(후.게놈) == 자식게놈요약 { 분기 = 후.분기 }
}
분기
}
// ── LOCK: 한 후보를 영구 형태로 고정 / lock one candidate into the permanent form ────────────────────
// 새 전투원: 형태(스프라이트/이름은 종 유지 — 같은 종 계통이지만)·새 게놈(돌연변이가 기준 게놈이 됨)·세대+1·새 계통시드·드리프트 0·진화카운터+1.
// 스탯은 종 기준치 + 레벨 + 스탯부스트로 다시 유도한다(부스트만큼 영구 상승). 체력은 새 최대로 회복(진화는 치유의 순간).
// New combatant: a new BASE genome (the mutation becomes the base), generation+1, a derived lineage seed, drift reset to 0,
// evoCounter+1. Stats re-derive from species base + level + the stat boost (a permanent rise). HP heals to the new max.
// 새 계통시드 = "{부모계통시드}>{분기}" → 다음 사이클은 이 시드에서 갈린다(같은 시드+같은 분기 → 같은 다음 게놈). 형태가 계속 쌓인다.
// New lineage seed = "{parentLineage}>{branch}" -> the next cycle splits from there; forms keep stacking into a multi-gen lineage.
function 새계통시드(부모계통시드: string, 분기: int) -> string {
"{부모계통시드}>{분기}"
}
// 세대를 [0, 세대상한] 으로 가둔다(조작/오버플로 방어; 진화·역직렬화 양쪽에서 호출). / Clamp generation into [0, cap].
export function 세대가두기(세대: int) -> int {
범위가두기(세대, 0, 세대상한)
}
// 진화카운터를 [0, 진화카운터상한] 으로 가둔다(밴드가 2^31 근처로 못 가게). / Clamp evolution counter into [0, cap].
export function 진화카운터가두기(카운터: int) -> int {
범위가두기(카운터, 0, 진화카운터상한)
}
// 분기 진화 LOCK: 부모 전투원 + 고른 분기 인덱스 + 부모 세대/진화카운터/스텝 → (잠긴 새 전투원 + 노드 재료).
// Branch-evolution LOCK: parent combatant + chosen branch index + parent generation/evoCounter/step -> (locked combatant + node materials).
// 분기 인덱스를 후보 범위로 가둔다(조작/범위 밖 방어). 같은 시드+같은 분기 → 같은 잠긴 형태(BYTE 동일). / branch clamped into range; reproducible.
export function 분기진화(부모: creature.전투원, 분기선택: int, 부모세대: int, 부모진화카운터: int, 스텝: int, 부모유아이디: int) -> 진화결과 {
let 엔티티 = 난수("mutation", 0, 0, 부모.계통시드) // 계통엔티티와 동일 규칙(시드 문자열이 스트림을 가름). / same rule as 계통엔티티.
let 카운터 = 진화카운터가두기(부모진화카운터)
let 후보들 = 후보들유도(부모.게놈, 엔티티, 부모.계통시드, 카운터)
// 분기 선택을 [0, 후보수-1] 로 가둔다(조작/범위 밖 → 0 분기로 안전 복귀). / clamp the choice into the candidate range.
let 분기 = 범위가두기(분기선택, 0, 후보들.length - 1)
let 고른후보 = 후보들[분기]
// 새 계통시드(다음 사이클의 분기점) + 새 유아이디(계통시드 해시). / new lineage seed (next split point) + new uid (lineage-seed hash).
let 시드 = 새계통시드(부모.계통시드, 분기)
let 새유아이디 = 난수("mutation", 0, 0, 시드)
let 새세대 = 세대가두기(부모세대 + 1)
// 스탯 재유도: 종 기준치 + 레벨 + 스탯부스트. 진화는 같은 종 계통이라 종아이디/스프라이트/속성/기술은 유지(IP 안전: 새 형태 이름만 파생).
// Re-derive stats: species base + level + boost. Same species lineage, so species id/sprite/type/moves stay; only the form-name is derived.
let 종정보 = 종찾기(부모.종아이디)
let 부스트 = 고른후보.스탯부스트
let 새최대 = creature.최대체력(종정보, 부모.레벨) + 부스트 * 2 // 진화 부스트로 최대체력 영구 상승 / max HP permanently up by the boost
let 새전투원 = 부모{
현재체력: 새최대, // 진화 = 완전 회복(새 최대) / evolution fully heals to the new max
최대체력: 새최대,
공격: 종정보.기본공격 + 부모.레벨 + 부스트,
방어: 종정보.기본방어 + 부모.레벨 + 부스트,
속도: 종정보.기본속도 + 부모.레벨 + 부스트,
계통시드: 시드, // 새 계통시드(다음 사이클의 분기점) / new lineage seed
드리프트: 0, // 드리프트 0 으로 리셋 — 다음 진화를 향한 새 느린 사이클 / fresh slow cycle
게놈: 고른후보.게놈, // 돌연변이 게놈이 새 기준 게놈이 된다 / the mutation becomes the new base genome
세대: 새세대, // 세대 +1(가둠) / generation +1 (clamped)
진화카운터: 진화카운터가두기(카운터 + 1), // 진화카운터 +1 — 다음 진화의 무작위 스트림을 띄운다 / evoCounter +1 for the next evolution's stream
형태이름: 고른후보.형태이름, // 영구 형태 이름(파생) / the permanent (derived) form-name
스탯부스트: 부모.스탯부스트 + 부스트 // 누적 스탯부스트(세대마다 쌓인다) / cumulative stat boost (stacks per generation)
}
{
전투원: 새전투원,
형태이름: 고른후보.형태이름,
분기: 분기,
세대: 새세대,
게놈요약전: genome.게놈요약(부모.게놈),
게놈요약후: genome.게놈요약(고른후보.게놈),
시드지문: 시드
}
}
// 진화 발사 판정: 준비(드리프트 임계) + 이번 배틀에서 레벨이 올랐는가. 둘 다 참이면 분기 진화가 일어난다(클래식 순간).
// Evolution trigger: READY (drift past threshold) AND a level was gained this battle. Both true -> branch-evolve (the classic moment).
export function 진화발사(드리프트: int, 오른레벨: int) -> bool {
genome.진화준비(드리프트) && 오른레벨 > 0
}
// 계통시드의 안정 유아이디/엔티티 = 난수("mutation",0,0,시드). 분기진화의 엔티티·노드유아이디와 같은 정의(같은 시드 → 같은 유아이디).
// 후보 메뉴(app)가 이 함수로 같은 엔티티를 얻어 분기진화와 똑같은 후보들을 재유도한다 → 메뉴에서 본 후보 = 잠기는 후보(재현).
// The stable uid/entity of a lineage seed = rand("mutation",0,0,seed). Same definition as the entity in 분기진화 and the node uid, so the
// candidate menu (app) re-derives the EXACT same candidates that 분기진화 will lock (what you see is what you lock; reproducible).
export function 계통유아이디(계통시드: string) -> int {
난수("mutation", 0, 0, 계통시드)
}
// 진화로 만든 계통 노드를 빌드한다(STATE 의 계통노드 리스트에 append 된다). 결정론·시드 박힘. / Build the lineage node for STATE.
export function 노드빌드(결과: 진화결과, 부모유아이디: int, 스텝: int) -> 계통노드 {
let 유 = 난수("mutation", 0, 0, 결과.시드지문)
{
유아이디: 유,
부모유아이디: 부모유아이디,
형태이름: 결과.형태이름,
게놈요약: 결과.게놈요약후,
세대: 결과.세대,
발견스텝: 스텝,
시드지문: 결과.시드지문
}
}
// ===== core/lineagedex.tpz =====
// Lineage Dex(계통수) — 프로젝트의 센터피스 시각화. 발견된 계통 노드 리스트에서 살아있는 계통수(phylogenetic tree) 레이아웃을
// 결정론적·순수하게 계산한다. 깊이 = 세대(뿌리는 왼쪽, 자손은 오른쪽으로 가지친다), 형제 순서는 발견 순서(리스트 인덱스)로 고정한다.
// 노드 (x,y) 위치 + 부모→자식 연결선(엣지)을 만든다. 모두 유계(상한·스크롤)라 노드가 많아도 행/넘침/오버플로가 없다.
// 같은 노드 리스트 → 같은 레이아웃(바이트 동일·재현). 시드 지문이 박혀 "이 시드가 이 나무를 키웠다"가 공유 가능해진다.
//
// Lineage Dex — the project's centerpiece visualization. From the discovered lineage-node list it computes a LIVING phylogenetic-tree
// layout, PURELY and DETERMINISTICALLY: depth = generation (roots left, descendants branch rightward), siblings ordered by DISCOVERY
// order (list index). It produces each node's (x,y) + the parent->child connector segments. Everything is BOUNDED (caps + scroll), so a
// large or crafted node count never hangs or overflows. Same node list -> the SAME layout (byte-identical, reproducible). The seed
// fingerprint is carried so "this seed grew this tree" is shareable.
//
// 하우스룰(core): export는 타입/함수/불변 최상위만. 최상위 가변·print·input·host(now_millis·파일IO·비트연산·미시드난수) 금지.
// deps는 안으로만(core->evolution 의 계통노드 타입 한정 참조만; 데이터·상태·배틀 import 안 함 — 작은 순수 잎 모듈). / pure; deps inward.
// House rule (core): export only types/functions/immutable bindings; no module-level mutable/print/input/host. Pure leaf; deps inward.
//
// ── 결정론 (필수) ───────────────────────────────────────────────────────────────────────────────────
// 레이아웃은 (노드 리스트)의 순수 함수다. 무작위 없음. 형제 순서 = 리스트 인덱스(발견 순서). 같은 리스트 → 같은 (열,행) → 같은 (x,y).
// The layout is a pure function of the node list. No randomness. Sibling order = list index (discovery order). Same list -> same (col,row) -> same (x,y).
//
// ── 유계·오버플로 안전 ──────────────────────────────────────────────────────────────────────────────
// 노드상한(가시 레이아웃 노드 수)·세대상한(열 폭)으로 모든 루프를 상수로 묶는다. 좌표는 작은 정수(열·행 * 간격)라 곱/합이 9.22e18 근처에 못 간다.
// 조작된 거대 노드 수/세대도 상한으로 가둬 행·넘침 없이 정지한다. 가시 영역(160x144)을 넘으면 전체 크기를 내려 JS가 스크롤한다.
// 노드상한 + 세대상한 cap every loop to a constant. Coords are small ints (col/row * spacing) -> no product/sum nears the checked-int bound.
// A crafted huge node count/generation is clamped, so it halts without overflow. The full extent is emitted so the JS can scroll past 160x144.
import core.evolution // §17: 계통노드 타입을 한정 참조(evolution.계통노드)하므로 모듈째 import. / whole-module import for the qualified 계통노드 type.
import core.util { 범위가두기, 하한가두기 }
// ── 레이아웃 상수 / layout constants ─────────────────────────────────────────────────────────────────
// 가시 레이아웃 노드 상한. 진짜 플레이로는 수십 개를 넘기 어렵지만, 조작된 거대 리스트가 레이아웃 루프(O(노드수^2)에 가까운 형제 스캔)를
// 폭주시키지 못하게 256 으로 묶는다. 그 너머 노드는 레이아웃에서 잘린다(도감은 가장 먼저 발견된 노드들을 보여준다). / cap visible layout nodes.
export let 노드상한 = 256
// 세대(열) 상한. evolution.세대상한(99)과 같다 — 열 인덱스가 그 너머로 못 가게 가둔다(가로 폭 유계). / column cap = generation cap.
export let 세대상한 = 99
// 한 열(세대)의 가로 픽셀 간격. col*열간격 + 여백 = x. 노드가 작아 22px 면 라벨/블롭이 들어간다. / horizontal px per generation column.
export let 열간격 = 34
// 한 행의 세로 픽셀 간격. row*행간격 + 여백 = y. / vertical px per row.
export let 행간격 = 22
// 트리 좌상단 여백(제목 줄 아래에서 시작). / tree top-left margin (below the title row).
export let 여백엑스 = 14
export let 여백와이 = 24
// ── 배치된 노드 / a placed node ──────────────────────────────────────────────────────────────────────
// 노드 한 개 + 레이아웃 좌표(열=세대, 행=배치 순서, x/y=픽셀). 인덱스는 노드 리스트의 발견 순서(커서가 가리키는 값). 부모인덱스는
// 엣지를 잇기 위한 부모의 배치 인덱스(없으면 -1 = 뿌리/고아). / one node + its layout coords (col/row/px). 부모인덱스 = parent's placed index (-1 = root/orphan).
export type 배치노드 = {
인덱스: int, // 노드 리스트에서의 인덱스(발견 순서, 커서 값) / index in the node list (discovery order, the cursor value)
유아이디: int,
부모인덱스: int, // 부모의 배치 인덱스(엣지용). 뿌리/고아는 -1. / parent's placed index for edges; -1 for a root/orphan
열: int, // = 세대(가둠) / column = generation (clamped)
행: int, // 배치 행(결정론) / placement row
엑스: int, // 픽셀 x = 여백엑스 + 열*열간격 / pixel x
와이: int, // 픽셀 y = 여백와이 + 행*행간격 / pixel y
형태이름: string,
게놈요약: string,
세대: int,
시드지문: string
}
// ── 레이아웃 결과 / the layout result ────────────────────────────────────────────────────────────────
// 배치된 노드들 + 트리 전체 픽셀 크기(가시영역을 넘으면 JS가 스크롤). 행수는 결정론 배치가 쓴 행의 개수. / placed nodes + full extent (for scrolling).
export type 계통레이아웃 = {
노드들: Array<배치노드>,
너비: int, // 트리 전체 픽셀 너비(가장 깊은 열까지) / full pixel width (to the deepest column)
높이: int, // 트리 전체 픽셀 높이(가장 아래 행까지) / full pixel height (to the bottom row)
행수: int // 배치에 쓴 행의 수 / number of rows used
}
// 세대를 [0, 세대상한] 으로 가둔다(조작/오버플로 방어; 열 인덱스로 직접 쓰임). / Clamp a generation into [0, cap] (used as a column index).
function 세대가두기(세대: int) -> int {
범위가두기(세대, 0, 세대상한)
}
// 부모유아이디로 부모의 노드-리스트 인덱스를 찾는다(없으면 -1 = 뿌리/고아). 부모유아이디 0 은 뿌리 약속이라 바로 -1.
// 같은 유아이디가 여럿이면(중복 불가 — 노드추가가 막음) 첫 일치를 쓴다(결정론). / Find a parent's list index by uid (-1 if absent = root/orphan).
function 부모인덱스찾기(노드들: Array<evolution.계통노드>, 부모유아이디: int) -> int {
if 부모유아이디 == 0 { return -1 } // 0 = 뿌리(부모 없음) / 0 means root (no parent)
let mut i = 0
while i < 노드들.length {
if 노드들[i].유아이디 == 부모유아이디 { return i }
i = i + 1
}
-1 // 부모가 리스트에 없으면 고아 → 뿌리처럼 다룬다 / parent not in list -> treat as a root (orphan defense)
}
// 한 노드가 다른 노드의 자식인가(부모유아이디 == 부모의 유아이디, 그리고 부모유아이디 != 0). / Is 자식 a child of 부모?
function 자식인가(자식: evolution.계통노드, 부모: evolution.계통노드) -> bool {
자식.부모유아이디 != 0 && 자식.부모유아이디 == 부모.유아이디
}
// 노드가 뿌리(또는 고아)인가: 부모유아이디 0 이거나, 부모유아이디가 리스트의 어떤 유아이디와도 안 맞으면 뿌리처럼 취급한다.
// Is the node a root (or orphan)? 부모유아이디 0, OR 부모유아이디 matches no uid in the list -> treated as a root.
function 뿌리인가(노드들: Array<evolution.계통노드>, 노드: evolution.계통노드) -> bool {
부모인덱스찾기(노드들, 노드.부모유아이디) < 0
}
// ── 행 배치(결정론 DFS 프리오더) ─────────────────────────────────────────────────────────────────────
// 뿌리들을 발견 순서(리스트 인덱스)로 순회하고, 각 뿌리에서 자식들을 발견 순서로 재귀 방문한다(스택 기반, 토파즈에 재귀 함수가 없어 명시 스택).
// 방문 순서대로 행 0,1,2,... 를 부여한다 → 자식이 부모 근처에 모이고, 가지가 위→아래로 펼쳐진다(계통수처럼). 같은 리스트 → 같은 행(결정론).
// 노드상한으로 방문 수를 묶어 폭주를 막는다(조작된 거대 리스트도 상수 시간 종료). 방문 안 된(상한 초과) 노드는 레이아웃에서 빠진다.
// Row assignment (deterministic DFS pre-order): walk roots in discovery order; from each root, recurse into children in discovery order
// (explicit stack — Topaz has no recursive fns). Assign rows 0,1,2,... in visit order, so children cluster near their parent and branches
// fan top->bottom (like a phylo tree). Same list -> same rows. Visits are capped by 노드상한 (a crafted huge list halts in constant time).
// 한 부모 인덱스의 자식 인덱스들을 발견 순서로 모은다(스택 푸시용; 역순으로 푸시해 팝 순서가 발견 순서가 되게 한다).
// Collect a parent's child indices in discovery order (we push them reversed so popping yields discovery order).
function 자식인덱스들(노드들: Array<evolution.계통노드>, 부모인덱스: int) -> Array<int> {
let mut 결과: Array<int> = []
let 부모 = 노드들[부모인덱스]
let mut i = 0
while i < 노드들.length {
if i != 부모인덱스 && 자식인가(노드들[i], 부모) { 결과.push(i) }
i = i + 1
}
결과
}
// 계통 노드 리스트로부터 트리 레이아웃을 계산한다(순수·결정론·유계). 핵심:
// 1) 뿌리들을 발견 순서로 찾는다(부모유아이디 0 또는 고아).
// 2) 각 뿌리에서 DFS 프리오더로 자손을 방문하며 행 0,1,2,.. 부여(자식은 발견 순서). 방문 상한 = 노드상한.
// 3) 열 = 세대(가둠). 픽셀 (x,y) = (여백 + 열*열간격, 여백 + 행*행간격). 부모인덱스로 엣지를 잇는다.
// 4) 트리 전체 크기(너비·높이)를 내려 JS가 스크롤한다.
// Compute the tree layout from the node list (pure, deterministic, bounded). Roots in discovery order -> DFS pre-order rows (children in
// discovery order, capped at 노드상한) -> column = clamped generation -> pixel coords -> parent edges -> full extent for scrolling.
export function 레이아웃계산(노드들: Array<evolution.계통노드>) -> 계통레이아웃 {
// 방문 표시(인덱스 → 방문됨). 토파즈 Array<bool> 로 노드 수만큼. 사이클/중복 방문 방지(결정론 + 무한루프 방지). / visited flags (cycle/dup guard).
let mut 방문: Array<bool> = []
let mut z = 0
while z < 노드들.length { 방문.push(false); z = z + 1 }
let mut 배치들: Array<배치노드> = []
let mut 다음행 = 0 // 다음에 부여할 행 번호(방문 순서) / next row to assign (visit order)
let mut 최대열 = 0 // 가장 깊은 열(너비 산출용) / deepest column (for width)
// DFS 스택: 방문할 노드 인덱스. 뿌리부터 시작하고, 각 노드의 자식들을 역순으로 푸시해 팝 순서 = 발견 순서가 되게 한다.
// DFS stack of node indices: start with roots; push each node's children reversed so popping yields discovery order.
let mut 스택: Array<int> = []
// 뿌리들을 역순으로 미리 스택에 쌓는다(팝 순서 = 발견 순서). 뿌리 = 부모가 리스트에 없는 노드(부모유아이디 0 포함).
// Pre-push roots reversed (pop order = discovery order). Root = a node whose parent is not in the list (incl. 부모유아이디 0).
let mut r = 노드들.length - 1
while r >= 0 {
if 뿌리인가(노드들, 노드들[r]) { 스택.push(r) }
r = r - 1
}
// DFS. 방문 상한(노드상한)으로 묶는다 — 조작된 거대/순환 리스트도 상수 시간에 종료한다. / DFS, capped by 노드상한 (halts on crafted/cyclic input).
let mut 방문수 = 0
while 스택.length > 0 && 방문수 < 노드상한 {
let 인덱스 = 스택[스택.length - 1]
스택 = 스택꺼냄(스택) // pop (새 배열 = 순수) / pop into a fresh array (pure)
if 인덱스 < 0 || 인덱스 >= 노드들.length { } // 방어(있을 수 없음) / defensive
else if 방문[인덱스] { } // 이미 방문(사이클/중복 — 건너뜀) / already visited (cycle/dup guard)
else {
방문 = 방문표시(방문, 인덱스)
let 노드 = 노드들[인덱스]
let 열 = 세대가두기(노드.세대)
let 행 = 다음행
다음행 = 다음행 + 1
if 열 > 최대열 { 최대열 = 열 }
let 부모인덱스 = 부모인덱스찾기(노드들, 노드.부모유아이디)
// 부모의 배치 인덱스(엣지용)를 찾는다: 부모 노드-인덱스를 이미 배치한 배치노드의 위치로 환산. 아직(또는 안) 배치됐으면 -1.
// The parent's PLACED index for edges: map the parent node-index to its position in 배치들. -1 if not placed (yet).
let 부모배치 = if 부모인덱스 < 0 { -1 } else { 배치인덱스찾기(배치들, 부모인덱스) }
배치들.push({
인덱스: 인덱스,
유아이디: 노드.유아이디,
부모인덱스: 부모배치,
열: 열,
행: 행,
엑스: 여백엑스 + 열 * 열간격,
와이: 여백와이 + 행 * 행간격,
형태이름: 노드.형태이름,
게놈요약: 노드.게놈요약,
세대: 노드.세대,
시드지문: 노드.시드지문
})
// 자식들을 역순으로 푸시(팝 순서 = 발견 순서). / push children reversed (pop order = discovery order).
let 자식들 = 자식인덱스들(노드들, 인덱스)
let mut c = 자식들.length - 1
while c >= 0 {
스택.push(자식들[c])
c = c - 1
}
방문수 = 방문수 + 1
}
}
// 트리 전체 픽셀 크기: 가장 깊은 열·가장 아래 행 + 노드 카드 크기(블롭 + 라벨 ≈ 30x18) 여유. 최소 가시영역(160x144) 이상은 보장.
// Full extent: deepest column / bottom row + node-card size (~30x18). Floored at the visible area (160x144) so the scroll math is safe.
let 너비 = 하한가두기(여백엑스 + 최대열 * 열간격 + 32, 160)
let 높이 = 하한가두기(여백와이 + 다음행 * 행간격 + 18, 144)
{ 노드들: 배치들, 너비: 너비, 높이: 높이, 행수: 다음행 }
}
// 스택(int 배열)에서 맨 위 하나를 뺀 새 배열을 만든다(토파즈 배열은 참조형이라 새 배열로 복사 = 순수). / Pop the top into a fresh array (pure).
function 스택꺼냄(스택: Array<int>) -> Array<int> {
let mut 결과: Array<int> = []
let mut i = 0
while i < 스택.length - 1 {
결과.push(스택[i])
i = i + 1
}
결과
}
// 방문 배열의 한 인덱스를 true 로 표시한 새 배열(순수 복사). / A fresh visited array with one index flipped true (pure copy).
function 방문표시(방문: Array<bool>, 인덱스: int) -> Array<bool> {
let mut 결과: Array<bool> = []
let mut i = 0
while i < 방문.length {
if i == 인덱스 { 결과.push(true) } else { 결과.push(방문[i]) }
i = i + 1
}
결과
}
// 이미 배치된 배치노드들에서, 주어진 노드-리스트 인덱스를 가진 노드의 배치 위치를 찾는다(엣지의 부모배치 산출). 없으면 -1.
// Find the placed position of a node by its node-list index (to wire an edge's parent). -1 if not yet placed.
function 배치인덱스찾기(배치들: Array<배치노드>, 노드리스트인덱스: int) -> int {
let mut i = 0
while i < 배치들.length {
if 배치들[i].인덱스 == 노드리스트인덱스 { return i }
i = i + 1
}
-1
}
// ===== core/auto.tpz =====
// 자동 진행(자동 사냥) 정책 — 순수·결정론. 상태에서 "다음 입력 하나"를 정한다(같은 엔진 적용이 그걸 적용 → 두 번째 엔진이 아님).
// JS 는 켜졌을 때 타이머로 step("auto")를 틱하고 그 사이를 애니메이션할 뿐(영화처럼). 분기 진화는 멈춰 플레이어가 고른다(트위스트 보존 = 게임 정체성).
// Auto-play policy (PURE, DETERMINISTIC): decide ONE ordinary input from STATE; the same step engine applies it. Branch evolution PAUSES for the player.
//
// 목표 우선순위(결정론): 위험하면 회복(NPC) → 가장 가까운 활성 야생과 전투 → 야생 없으면 워프로 내려감. 분기 선택 장면은 무행동(멈춤).
// Objective agenda: heal if unsafe -> fight the nearest active wild -> descend via warp if none. The branch-choice scene returns no-op (pause).
//
// 하우스룰(core): export는 타입/함수/불변 최상위만. 비트연산·미시드난수·now_millis·파일IO 금지. 결정론은 사활(자동도 시드+입력의 순수함수).
import data.maps { 맵너비, 맵높이, 맵타일들, 타일에서, 워프목록, 타일_물, 타일_나무 }
import data.entities { 엔티티목록, 활성엔티티에서, 활성엔티티있음, 처치포함, 엔티티_야생, 엔티티_NPC }
import core.state
import core.creature
import core.evolution { 후보수, 계통유아이디, 진화시작 } // 슬라이스2: 진화 의도(자동 분기 선택)용 후보 수 + 결정론 굴림 밴드 / for the cultivation-intent auto branch pick
import core.rng { 난수범위 }
import core.util { 범위가두기 }
// 자동 이동에서 통행 가능한 칸인가: 물(3)·나무(4)·활성 엔티티 칸이 아니면 가능. 목표(엔티티·워프) 자체는 BFS 소스로 따로 0거리 처리.
// Walkable for nav: not water/tree/an active-entity tile. (Goal cells are seeded as BFS sources regardless.)
function 통행가능(타일들: Array<int>, 맵아이디: string, 엑스: int, 와이: int, 처치됨: Array<int>) -> bool {
let t = 타일에서(타일들, 엑스, 와이)
if t == 타일_물 || t == 타일_나무 { return false }
if 활성엔티티있음(맵아이디, 엑스, 와이, 처치됨) { return false }
true
}
// 다중 소스 BFS 거리장(소스=목표 칸, 0거리). 통행 가능 칸으로만 확장. 도달 못 하면 -1. 크기 맵너비*맵높이. 고정 이웃 순서(아래·위·왼·오른) → 결정론.
// Multi-source BFS distance field: sources (goal cells) = 0; expand only through walkable cells; -1 = unreachable. Fixed neighbour order -> deterministic.
function 거리장(소스들: Array<int>, 타일들: Array<int>, 맵아이디: string, 처치됨: Array<int>) -> Array<int> {
let W = 맵너비
let 칸수 = 맵너비 * 맵높이
let mut 거리: Array<int> = []
let mut i = 0
while i < 칸수 { 거리.push(-1); i = i + 1 }
let mut 큐: Array<int> = []
for s in 소스들 {
if s >= 0 && s < 칸수 && 거리[s] == -1 { 거리[s] = 0; 큐.push(s) }
}
let mut 머리 = 0
while 머리 < 큐.length {
let c = 큐[머리]
머리 = 머리 + 1
let cx = c % W
let cy = c / W
let d = 거리[c]
let mut k = 0
while k < 4 {
let mut nx = cx
let mut ny = cy
if k == 0 { ny = cy + 1 } else if k == 1 { ny = cy - 1 } else if k == 2 { nx = cx - 1 } else { nx = cx + 1 }
if nx >= 0 && ny >= 0 && nx < W && ny < 맵높이 {
let ni = ny * W + nx
if 거리[ni] == -1 && 통행가능(타일들, 맵아이디, nx, ny, 처치됨) {
거리[ni] = d + 1
큐.push(ni)
}
}
k = k + 1
}
}
거리
}
// 거리장에서 플레이어가 목표로 한 칸 다가갈 방향. 더 가까운(거리 작은) 이웃을 고정 순서(아래·위·왼·오른) 첫 번째로. 진전 없으면 "".
// The direction one step toward the goal (the strictly-closer neighbour, fixed order, first wins). "" if no closer neighbour (at/blocked).
function 다가가기방향(거리: Array<int>, 엑스: int, 와이: int) -> string {
let W = 맵너비
let pi = 와이 * W + 엑스
let mut 내거리 = -1
if pi >= 0 && pi < 거리.length { 내거리 = 거리[pi] }
let mut 최선거리 = 내거리
let mut 최선방향 = ""
let mut k = 0
while k < 4 {
let mut nx = 엑스
let mut ny = 와이
let mut 방향 = ""
if k == 0 { ny = 와이 + 1; 방향 = "down" } else if k == 1 { ny = 와이 - 1; 방향 = "up" } else if k == 2 { nx = 엑스 - 1; 방향 = "left" } else { nx = 엑스 + 1; 방향 = "right" }
if nx >= 0 && ny >= 0 && nx < W && ny < 맵높이 {
let nd = 거리[ny * W + nx]
if nd >= 0 && (최선거리 < 0 || nd < 최선거리) {
최선거리 = nd
최선방향 = 방향
}
}
k = k + 1
}
최선방향
}
// 종류별 목표 칸 인덱스 모음. 활성 야생 / NPC / 워프. / Goal-cell index lists by kind: active wilds / NPCs / warps.
// 심연군주(보스) 엔티티 아이디 — 평범한 야생 타깃에서 제외하고 "피날레"로만 다룬다. / the Abyss Lord's entity id; excluded from normal wild targeting, handled only as the finale.
let 보스아이디 = 14
// 활성 야생 칸들 — 단, 보스(14)는 제외(피날레는 따로). / active wild cells, EXCLUDING the boss (the finale is targeted separately).
function 야생칸들(맵아이디: string, 처치됨: Array<int>) -> Array<int> {
let mut r: Array<int> = []
for e in 엔티티목록(맵아이디) {
if e.종류 == 엔티티_야생 && e.아이디 != 보스아이디 && !처치포함(처치됨, e.아이디) { r.push(e.와이 * 맵너비 + e.엑스) }
}
r
}
// 보스(피날레) 칸 — 살아 있으면 그 칸 하나. / the boss (finale) cell, if still active.
function 보스칸(맵아이디: string, 처치됨: Array<int>) -> Array<int> {
let mut r: Array<int> = []
for e in 엔티티목록(맵아이디) {
if e.아이디 == 보스아이디 && !처치포함(처치됨, e.아이디) { r.push(e.와이 * 맵너비 + e.엑스) }
}
r
}
function npc칸들(맵아이디: string) -> Array<int> {
let mut r: Array<int> = []
for e in 엔티티목록(맵아이디) {
if e.종류 == 엔티티_NPC { r.push(e.와이 * 맵너비 + e.엑스) }
}
r
}
function 워프칸들(맵아이디: string) -> Array<int> {
let mut r: Array<int> = []
for w in 워프목록(맵아이디) { r.push(w.와이 * 맵너비 + w.엑스) }
r
}
// 마주 본 칸 좌표. 0아래·1위·2왼·3오른. / the faced tile.
function 마주엑스(플레이어: state.플레이어) -> int {
if 플레이어.방향 == 2 { 플레이어.엑스 - 1 } else if 플레이어.방향 == 3 { 플레이어.엑스 + 1 } else { 플레이어.엑스 }
}
function 마주와이(플레이어: state.플레이어) -> int {
if 플레이어.방향 == 1 { 플레이어.와이 - 1 } else if 플레이어.방향 == 0 { 플레이어.와이 + 1 } else { 플레이어.와이 }
}
// 초점(키우는 중) 전투원. 초점은 역직렬화에서 가둬지지만 방어적으로 한 번 더. / the focused creature (defensive clamp).
function 초점전투원(에스: state.상태) -> creature.전투원 {
let f = 범위가두기(에스.초점, 0, 에스.파티.length - 1)
에스.파티[f]
}
// 진화 장면의 자동 행동(재배 의도). morph 단계 = 진행(아무 키). choose 단계 = 의도에 따라:
// 0 수동 = 무행동(멈춤) → 플레이어가 갈림길을 짓는다(기본·트위스트 보존). 1 첫 가지(K=0). 그 외 = 무작위(결정론 굴림).
// 공개 정보(후보 수)만 쓴다 — 숨은 스탯으로 최적화하는 봇이 되지 않게. 커서를 목표 분기 K로 옮긴 뒤 잠근다.
// Auto behaviour at evolution (cultivation intent). morph -> advance; choose -> by intent: 0 manual(pause, default) / 1 leftmost / else seeded-random.
// Uses only the PUBLIC candidate count (no hidden-stat optimiser). Walks the cursor to the target branch K, then locks.
function 진화자동(에스: state.상태, 의도: int) -> string {
let 진 = 에스.진화
match 진.단계 {
case "morph" => { return "a" } // 모핑 마무리 → 필드 / finish the morph -> field
case "none" => { return "" }
case "choose" => ()
}
if 의도 == 0 { return "" } // 수동: 멈춰 플레이어가 고른다 / manual: pause for the player
let 엔티티 = 계통유아이디(진.부모계통시드)
let 수 = 후보수(엔티티, 진.부모계통시드, 진.부모진화카운터)
if 수 <= 1 { return "a" } // 분기 하나면 바로 잠금 / single branch -> lock
let mut K = 0
if 의도 == 1 {
K = 0 // 첫 가지(왼쪽) / leftmost
} else {
// 무작위(결정론): 진화 굴림 밴드의 난수로 공개 후보 중 하나. 같은 진화 → 같은 K. / deterministic random over the public candidates.
K = 난수범위("auto_evolve", 엔티티, 진화시작 + 진.부모진화카운터, 진.부모계통시드, 0, 수 - 1)
}
K = 범위가두기(K, 0, 수 - 1)
if 진.선택커서 < K { return "right" } // 커서를 K로 / walk the cursor to K
if 진.선택커서 > K { return "left" }
"a" // K 도달 → 잠금 / at K -> lock
}
// ── 자동 결정: (상태, 의도) → 다음 입력 이벤트. ""(무행동)이면 멈춤(수동 진화 갈림길 등 플레이어 몫). ───────────
// Auto decision: (STATE, intent) -> the next input event. "" (no-op) = PAUSE (e.g. a manual branch choice the player must make).
export function 자동결정(에스: state.상태, 의도: int) -> string {
// 배틀: 메뉴 커서 기본값 0(싸운다) → 'a' 가 싸운다→기술0→메시지진행 을 끝까지 몬다. 가장 단순·결정론적인 전투(슬라이스1).
// Battle: the menu cursor defaults to 0 (FIGHT), so 'a' drives FIGHT -> move0 -> advance messages to the end. Simplest deterministic combat.
match 에스.장면 {
case "battle" => { return "a" }
// 스토리 카드: 자동은 'a' 로 넘긴다(JS 가 읽을 시간만큼 텀을 둔다 → 영화처럼). 선택이 아니라 멈출 이유 없음. / story card: advance with 'a' (JS paces the delay so it stays readable).
case "story" => { return "a" }
// 진화: 재배 의도에 따라 멈추거나(기본·수동) 공개 정보로 결정론적 분기 선택(자동). / evolution: pause (manual default) or pick deterministically by intent.
case "evolve" => { return 진화자동(에스, 의도) }
// 파티/도감은 자동 흐름이 아니다 → 닫고 필드로. / party/dex aren't part of the auto loop -> close back to field.
case "party" => { return "start" }
case "dex" => { return "select" }
case "field" => ()
}
// ── 필드 정책 ──
let 타일들 = 맵타일들(에스.맵아이디)
let 활성 = 초점전투원(에스)
// 위험 판정: 초점 동료 체력 < 1/3. / unsafe = focused creature below 1/3 HP.
let 위험 = 활성.현재체력 * 3 < 활성.최대체력
// 비보스 야생들(이 맵). 보스는 이게 다 비어야만(피날레) 싸운다. / non-boss wilds on this map; the boss is fought only when these are all cleared (the finale).
let 비보스야생 = 야생칸들(에스.맵아이디, 에스.처치된것들)
// 마주 본 칸: 비보스 야생 → 전투. 보스 → 다른 야생 다 비운 뒤에만(스쳐 지나다 일찍 들이받지 않게). NPC+위험 → 휴식.
// Faced tile: a non-boss wild -> fight; the BOSS -> only once all non-boss wilds are cleared (don't bump it early while pathing past); NPC + unsafe -> rest.
let 앞 = 활성엔티티에서(에스.맵아이디, 마주엑스(에스.플레이어), 마주와이(에스.플레이어), 에스.처치된것들)
if 앞.종류 == 엔티티_야생 && 앞.아이디 != 보스아이디 { return "a" }
if 앞.아이디 == 보스아이디 && 비보스야생.length == 0 { return "a" }
if 앞.종류 == 엔티티_NPC && 위험 { return "a" }
// 목표: 위험→NPC. 아니면 (1) 남은 비보스 야생을 다 비우고 → (2) 연못이면 워프로 내려가 → (3) 심층이면 비보스 다 비운 뒤 보스(피날레)로. 키운 게놈으로 보스를 압도한다.
// Goal: heal if unsafe; else clear all non-boss wilds, then (pond) descend, then (deep, all cleared) approach the boss finale. The cultivated genome overpowers it.
let mut 목표: Array<int> = []
if 위험 {
목표 = npc칸들(에스.맵아이디)
} else if 비보스야생.length > 0 {
목표 = 비보스야생
} else if 에스.맵아이디 == "deep" {
목표 = 보스칸(에스.맵아이디, 에스.처치된것들)
} else {
목표 = 워프칸들(에스.맵아이디)
}
if 목표.length == 0 { return "" }
// BFS 거리장(목표=0거리) → 한 칸 다가갈 방향. 목표가 엔티티(통행 불가)면 인접에서 그 방향을 내 → 다음 틱에 마주 보고 A. 워프(통행 가능)면 그 칸으로 들어가 전이.
// BFS field (goals = 0) -> the step toward the goal. For an entity goal (blocked), the player ends up facing it (then A next tick); for a warp (walkable), the player steps onto it.
let 거리 = 거리장(목표, 타일들, 에스.맵아이디, 에스.처치된것들)
다가가기방향(거리, 에스.플레이어.엑스, 에스.플레이어.와이)
}
// ── 자동 목표(영화 폴리시): 지금 자동이 무엇을 하는지 한 줄로. 자동결정과 같은 우선순위를 읽되 텍스트만 낸다(상태의 순수 함수 → 자동 꺼져 있어도 안전). JS 가 자동 켜졌을 때 HUD 로 보여 준다.
// Auto objective (movie polish): a one-line description of what auto is doing now — same priority as 자동결정 but text only (a pure function of state). The JS shows it as a HUD when auto is on.
export function 자동목표(에스: state.상태) -> string {
match 에스.장면 {
case "battle" => { return "야생과 싸우는 중" }
case "evolve" => { return "진화의 갈림길" }
case "party" => { return "파티를 보는 중" }
case "dex" => { return "계통을 보는 중" }
case "story" => ()
case "field" => ()
}
let 활성 = 초점전투원(에스)
if 활성.현재체력 * 3 < 활성.최대체력 { return "회복하러 가는 중" }
let 야생들 = 야생칸들(에스.맵아이디, 에스.처치된것들)
if 야생들.length > 0 { return "야생을 찾아가는 중" }
// 심층: 야생을 다 비우면 보스(피날레)로, 보스까지 잡았으면 완성. 워프는 비심층 하강에만 쓴다(자동결정과 일치 → HUD 거짓말 방지). / mirror 자동결정 in the deep so the HUD never lies.
if 에스.맵아이디 == "deep" {
if 보스칸(에스.맵아이디, 에스.처치된것들).length > 0 { return "심연군주에게 가는 중" }
return "정복 완료"
}
if 워프칸들(에스.맵아이디).length > 0 { return "더 깊은 곳으로 가는 중" }
"둘러보는 중"
}
// ===== core/story.tpz =====
// 스토리 큐(슬라이스3): 마일스톤을 한 번만 띄우는 내레이션. 비트셋은 STATE에 저장(결정론·세이브·리플레이에 포함). 순수 — 게임 결과(계통·숲)엔 영향 없음(연출 진행).
// Story queue: fire-once milestone narration. Bitsets live in STATE (deterministic, in the save/replay). Pure presentation — no effect on the lineage/forest outcome.
//
// 비트 enum(append-only). 슬라이스3 구현: 0 첫 조우 · 1 첫 포획 · 2 첫 초점 · 7 첫 심층 · 10 정복. (3·4·5·6·8·9 는 후속 비트.)
// Beat enum (append-only). Slice 3 ships 5: first encounter / first catch / first focus / first deep entry / conquest.
import data.entities { 엔티티목록, 처치포함, 엔티티_야생 }
import core.state
export let 비트수 = 15
export let 비트_첫조우 = 0
export let 비트_첫포획 = 1
export let 비트_첫초점 = 2
export let 비트_첫심층 = 7
export let 비트_정복 = 10
// 2의 거듭제곱(토파즈엔 비트연산이 없어 산술 비트셋을 쓴다). k 는 [0,14] 가정. / 2^k (arithmetic bitset; Topaz has no bitwise operators).
function 거듭(k: int) -> int {
let mut v = 1
let mut i = 0
while i < k && i < 30 { v = v * 2; i = i + 1 }
v
}
// n 의 k 비트가 켜져 있나. / is bit k set?
export function 비트있음(n: int, k: int) -> bool { (n / 거듭(k)) % 2 == 1 }
// n 에 k 비트를 켠다(중복 안전). / set bit k (idempotent).
export function 비트켜기(n: int, k: int) -> int { if 비트있음(n, k) { n } else { n + 거듭(k) } }
// n 의 k 비트를 끈다(켜져 있을 때만). / clear bit k.
export function 비트끄기(n: int, k: int) -> int { if 비트있음(n, k) { n - 거듭(k) } else { n } }
// 최하위 켜진 비트 인덱스(없으면 -1) — 큐에서 다음에 보여줄 비트. / lowest set bit index (-1 if none) = the next beat to show.
export function 최하위비트(n: int) -> int {
let mut k = 0
while k < 비트수 {
if 비트있음(n, k) { return k }
k = k + 1
}
-1
}
// 지금 상태에서 "도달한" 마일스톤 비트셋(상태의 순수 함수, 유도). 호출자가 본것을 빼 새 비트만 추린다.
// The milestone bitset reached as of this state (a pure function of state). The caller subtracts 'seen' to get the new beats.
export function 파생비트(에스: state.상태) -> int {
let mut b = 0
// 0 첫 조우: 맵에 야생이 보인다(시작부터 참 → 오프닝 카드로 톤을 깐다). / a visible wild exists (true at start -> the opening card sets the tone).
let mut 야생있음 = false
for e in 엔티티목록(에스.맵아이디) { if e.종류 == 엔티티_야생 { 야생있음 = true } }
if 야생있음 { b = 비트켜기(b, 비트_첫조우) }
// 1 첫 포획: 파티가 시작종 하나를 넘어 늘었다. / the party grew past the starter.
if 에스.파티.length > 1 { b = 비트켜기(b, 비트_첫포획) }
// 2 첫 초점: 비시작 동료를 초점(키우기). / focused a non-starter.
if 에스.초점 > 0 { b = 비트켜기(b, 비트_첫초점) }
// 7 첫 심층: 심층 생물군계에 들어섰다. / entered the Deep.
if 에스.맵아이디 == "deep" { b = 비트켜기(b, 비트_첫심층) }
// 10 정복: 심연군주(엔티티 14)를 처치했다. / the Abyss Lord (entity 14) is cleared.
if 처치포함(에스.처치된것들, 14) { b = 비트켜기(b, 비트_정복) }
b
}
// 비트별 내레이션(합니다체, 느림·인내의 톤, 원작 미생물 세계관). 두 줄은 \n. 미구현 비트는 "". / per-beat narration (two lines via \n); "" for unimplemented bits.
export function 비트내레이션(비트: int) -> string {
if 비트 == 비트_첫조우 { return "세계가 조금씩 얇아지고 있습니다.\n저 작은 표본은 아직 사라지지 않은 생명의 결입니다." }
if 비트 == 비트_첫포획 { return "한 표본이 씨앗의 숲에 머물기로 했습니다.\n서두르지 마십시오. 오래 지켜본 생명만이 길을 냅니다." }
if 비트 == 비트_첫초점 { return "이제 이 표본은 하나의 줄기가 됩니다.\n당신의 보살핌은 계보가 되어 남습니다." }
if 비트 == 비트_첫심층 { return "깊은 곳이 열렸습니다.\n그곳은 적의 땅이 아니라, 오래전에 멈춘 생명의 뿌리입니다." }
if 비트 == 비트_정복 { return "숲은 사라지지 않았습니다.\n느리게 자란 계보가, 다시 살아갈 수 있음을 증명했습니다." }
""
}
// ===== core/clash.tpz =====
// 삼투 압력전(Osmosis Clash) — 독자적·결정론·자동영화용 전투. 포켓몬식 턴/기술메뉴를 대체한다.
// 두 미생물이 한 유체 주머니에서 서로를 불안정하게 만든다. 메뉴 없음 — 당신이 "키운 게놈"이 곧 전투력. 코어가 비트(beat)마다 결정론적으로 계산하고 JS가 영화처럼 그린다.
// Osmosis Clash: an original, deterministic, watch-first battle replacing the Pokemon-style turn/menu. The genome you CULTIVATED is your combat power.
//
// 형질→역할: 색소=적응(받는 압력 완화) · 막=응집/방어 · 크기=부피/지구력 · 대사=부식/지속 피해 · 촉수=접촉/템포.
// 안정도(cohesion): 내 = 전투원 현재체력, 적 = 배틀.적체력. 압력(pressure, -100..100, 표시용 모멘텀) = 배틀.메뉴커서. 비트 인덱스 = 배틀.전투카운터.
// trait roles: 색소 adapt / 막 cohesion / 크기 volume / 대사 corrosion / 촉수 tempo. Cohesion: mine = HP, foe = 적체력. Pressure meter = 메뉴커서. Beat = 전투카운터.
import core.genome
import core.creature
import core.battle
import core.rng { 난수범위 }
import core.util { 범위가두기, 하한가두기 }
import data.species { 종찾기 }
export let 비트상한 = 8 // 비트 캡(보통 8). 도달하면 안정도 우위로 판정. / beat cap; at the cap the higher cohesion% wins.
let 포획문턱 = 30 // 적 안정도가 최대의 이 % 이하면 포자 정착(포획) 시도. / capture window when foe cohesion <= 30% of max.
// 형질을 [0, 강도상한) 으로 가둔다. / clamp a trait into [0, 강도상한).
function 형질가두기(v: int) -> int {
범위가두기(v, 0, genome.강도상한 - 1)
}
// 적(야생) 게놈: 야생크리처엔 게놈이 없으므로 유도한다. 크기는 레벨에 비례(난이도 곡선: 얕은 연못 약함 → 심연군주 강함), 어떤 형질이 두드러지는지는 종·시드 풍미로.
// Foe genome (the wild carries none): magnitude scales with LEVEL (a difficulty curve — shallow pond weak, abyss lord strong); WHICH traits lean comes from a species/seed flavor.
export function 적게놈유도(종아이디: int, 적레벨: int, 시드: string) -> genome.게놈 {
let 풍미 = genome.게놈유도("{시드}#foe@{종아이디}")
let 단위 = 적레벨 / 3
{
색소: 형질가두기(단위 + 풍미.색소 % 2), 막: 형질가두기(단위 + 풍미.막 % 2), 크기: 형질가두기(단위 + 풍미.크기 % 2),
대사: 형질가두기(단위 + 풍미.대사 % 2), 촉수: 형질가두기(단위 + 풍미.촉수 % 2)
}
}
// 적 안정도 최대치 = 동급 전투원의 체력 곡선(기본체력 + 레벨*2) — 플레이어 체력과 같은 식이라 공정하게 균형 잡힌다. / foe max cohesion = the same HP curve as a creature, so it's balanced against the player.
export function 적안정도최대(적종: int, 적레벨: int) -> int { creature.최대체력(종찾기(적종), 적레벨) }
// 게놈의 우세 형질 인덱스(0색소·1막·2크기·3대사·4촉수). 동률은 낮은 인덱스. / the dominant trait index (ties -> lowest index).
export function 우세형질(g: genome.게놈) -> int {
let mut 최선 = 0
let mut 값 = g.색소
if g.막 > 값 { 값 = g.막; 최선 = 1 }
if g.크기 > 값 { 값 = g.크기; 최선 = 2 }
if g.대사 > 값 { 값 = g.대사; 최선 = 3 }
if g.촉수 > 값 { 값 = g.촉수; 최선 = 4 }
최선
}
// 형질 비트 문구(합니다체, 미생물 톤). side="내"/"적". / a trait-fire line for the beat (polite Korean).
function 형질문구(편: string, 형질: int) -> string {
if 형질 == 0 { return "{편} 색소가 압력을 휘게 합니다" }
if 형질 == 1 { return "{편} 막이 버팁니다" }
if 형질 == 2 { return "{편} 부피가 버팁니다" }
if 형질 == 3 { return "{편} 대사가 갉아냅니다" }
"{편} 촉수가 파고듭니다"
}
// 한 편의 공세량 = 형질(대사 부식 + 촉수 템포 + 크기 질량) + 키운 만큼의 보너스(레벨·진화 부스트·세대). 키운 게놈이 곧 전투력 — 야생을 다 비운 플레이어가 보스를 압도하게 한다.
// One side's offense = traits + a CULTIVATION bonus (level, evolution stat-boost, generation). The boss has no evolution, so a cleared-the-map player out-powers it.
function 공세(g: genome.게놈, 레벨: int, 부스트: int, 세대: int) -> int { g.대사 * 3 + g.촉수 * 2 + g.크기 + 레벨 * 2 + 부스트 * 3 + 세대 * 3 }
// 한 편의 방어량 = 형질(막 응집 + 크기 부피) + 키운 보너스. / defense = traits + cultivation bonus.
function 방어(g: genome.게놈, 레벨: int, 부스트: int, 세대: int) -> int { g.막 * 3 + g.크기 + 레벨 + 부스트 * 2 + 세대 * 2 }
// 압력전 시작 상태. 단계 "clash", 비트 0, 압력 0(중립), 적 안정도 = 적최대(체력 곡선). / start the clash; foe cohesion = the HP curve.
export function 압력전시작(적종: int, 적레벨: int) -> battle.배틀상태 {
{
단계: "clash", 메뉴커서: 0, 기술커서: 0, 전투카운터: 0,
적체력: 적안정도최대(적종, 적레벨), 메시지: "두 세포가 한 주머니에서 맞닿습니다.", 메시지커서: 0, 다음단계: "clash", 결과: ""
}
}
// 포자 정착(포획) 결정론 판정. 점수 = 볼 + 약화량 + 색소친화 - 적막 + 시드굴림. 성공이면 true. / deterministic spore-settle (capture) roll.
function 포자정착판정(시드: string, 비트: int, 적종: int, 적게놈: genome.게놈, 적안정도: int, 적최대: int, 볼: int, 내색소: int) -> bool {
if 볼 <= 0 { return false }
let 약화 = (적최대 - 적안정도) * 100 / (적최대 + 1) // 약화 비율(0..100) / how weakened (%)
let 점수 = 볼 * 8 + 약화 + 내색소 * 2 - 적게놈.막 * 3
let 굴림 = 난수범위("spore", 적종, 비트, 시드, 0, 100)
점수 > 굴림 + 40
}
// 비트 하나를 진행한다(순수·결정론). 압력·양측 안정도·비트++ 갱신, 해소되면 단계=result + 결과(win/lose/caught) 설정. 반환: {배틀, 플레이어}.
// Advance ONE beat (pure, deterministic): update pressure + both cohesions + beat; on resolution set 단계="result" and 결과. Returns the new battle + creature.
export function 비트진행(시드: string, 비: battle.배틀상태, 활성: creature.전투원, 적종: int, 적레벨: int, 볼: int, 포획원함: bool) -> battle.턴결과 {
let 비트 = 비.전투카운터
let 적최대 = 적안정도최대(적종, 적레벨)
let p = genome.표현형(활성.게놈, 활성.계통시드, 활성.드리프트) // 드리프트 반영된 발현 게놈 = 실제 전투력 / expressed genome (drift counts)
let f = 적게놈유도(적종, 적레벨, 시드)
// 양측 피해(공세 - 상대 방어, /4 로 눅여 ~5-8 비트 지속 = 감상). 색소는 받는 피해를 완화(적응). 적 피해 최소 2(전투 진행 보장). / damped damage so a clash lasts ~5-8 beats (watchable); 색소 mitigates incoming.
// 플레이어는 키운 보너스(레벨·부스트·세대) 적용, 적(야생)은 레벨만(진화 없음). / player gets the cultivation bonus; the foe (wild) gets level only, no evolution.
let 지터내 = 난수범위("clash_p", 적종, 비트, 시드, 0, 2)
let 적피해 = 하한가두기((공세(p, 활성.레벨, 활성.스탯부스트, 활성.세대) - 방어(f, 적레벨, 0, 0)) / 4 + 지터내, 2)
let 지터적 = 난수범위("clash_f", 적종, 비트, 시드, 0, 2)
let 내피해 = 하한가두기((공세(f, 적레벨, 0, 0) - 방어(p, 활성.레벨, 활성.스탯부스트, 활성.세대)) / 4 + 지터적 - p.색소 / 2, 0)
let 새적 = 비.적체력 - 적피해
let 새체력 = 하한가두기(활성.현재체력 - 내피해, 0)
// 압력 모멘텀(표시용): 이번 비트의 우열을 누적. + = 내가 민다. / pressure momentum (display): accumulate this beat's edge.
let 압력 = 범위가두기(비.메뉴커서 + (적피해 - 내피해), -100, 100)
let 메시지 = "{형질문구("내", 우세형질(p))} · {형질문구("적", 우세형질(f))}"
let 다음비트 = 비트 + 1
let 새활성 = creature.체력설정(활성, 새체력)
// ── 해소 판정 ── / resolution
// 패배 우선(내 안정도 0). / loss first.
if 새체력 <= 0 {
return { 배틀: 비{ 단계: "result", 메뉴커서: 압력, 기술커서: 0, 전투카운터: 다음비트, 적체력: 새적, 메시지: "버티지 못하고 흩어집니다.", 메시지커서: 0, 다음단계: "field", 결과: "lose" }, 플레이어: 새활성 }
}
// 승리(적 안정도 0). / win.
if 새적 <= 0 {
return { 배틀: 비{ 단계: "result", 메뉴커서: 압력, 기술커서: 0, 전투카운터: 다음비트, 적체력: 0, 메시지: "상대가 더는 응집하지 못합니다.", 메시지커서: 0, 다음단계: "field", 결과: "win" }, 플레이어: 새활성 }
}
// 포획 창(적 약화) → 포자 정착 시도. 단, 포획을 "원할" 때만(숲을 몇 그루 만들 때). 기본은 쓰러뜨려 경험·드리프트를 얻어 초점을 키운다 — 안 그러면 다 잡아만 버려 진화를 못 한다.
// Capture window only fires when we WANT to catch (seeding a few forest roots). By default DEFEAT for EXP/drift to cultivate the focus — otherwise catching everything starves evolution.
if 포획원함 && 새적 * 100 <= 적최대 * 포획문턱 {
if 포자정착판정(시드, 비트, 적종, f, 새적, 적최대, 볼, p.색소) {
return { 배틀: 비{ 단계: "result", 메뉴커서: 압력, 기술커서: 0, 전투카운터: 다음비트, 적체력: 새적, 메시지: "포자가 상대를 감싸 정착시킵니다.", 메시지커서: 0, 다음단계: "catch", 결과: "caught" }, 플레이어: 새활성 }
}
}
// 비트 캡 → 안정도 우위로 판정. / beat cap -> the higher cohesion% wins.
if 다음비트 >= 비트상한 {
let 내비율 = 새체력 * 100 / (활성.최대체력 + 1)
let 적비율 = 새적 * 100 / (적최대 + 1)
if 내비율 >= 적비율 {
return { 배틀: 비{ 단계: "result", 메뉴커서: 압력, 기술커서: 0, 전투카운터: 다음비트, 적체력: 새적, 메시지: "압력이 가라앉고, 당신 쪽이 더 안정적입니다.", 메시지커서: 0, 다음단계: "field", 결과: "win" }, 플레이어: 새활성 }
}
return { 배틀: 비{ 단계: "result", 메뉴커서: 압력, 기술커서: 0, 전투카운터: 다음비트, 적체력: 새적, 메시지: "압력이 가라앉고, 당신 쪽이 더 흔들립니다.", 메시지커서: 0, 다음단계: "field", 결과: "lose" }, 플레이어: 새활성 }
}
// 계속. / continue.
{ 배틀: 비{ 단계: "clash", 메뉴커서: 압력, 기술커서: 0, 전투카운터: 다음비트, 적체력: 새적, 메시지: 메시지, 메시지커서: 0, 다음단계: "clash", 결과: "" }, 플레이어: 새활성 }
}
// ===== data/maps.tpz =====
// 원시 연못 바이옴: 시작 맵(타일 아이디 격자). 오리지널 레이아웃.
// 토파즈는 그릴 수 없다: 여기서는 타일 아이디(int) 데이터만 내보내고, JS 셸이 각 아이디를 16x16 픽셀 패턴으로 그린다.
// Primordial-pond biome: the starting map as a grid of tile ids. Original layout.
// Topaz cannot draw — this module exports only tile-id DATA (ints); the JS shell renders each id as a 16x16 pixel pattern.
//
// 하우스룰(data): 오리지널 콘텐츠만(순수·불변). 엔진·Host 안 건드린다. / House rule: original content only, pure; no engine/Host.
//
// 타일 아이디: 0 풀 / 1 키큰풀(조우 후보) / 2 길 / 3 물 / 4 나무 / 5 꽃.
// Tile ids: 0 grass / 1 tall-grass(encounter candidate) / 2 path / 3 water / 4 tree / 5 flower.
// 맵은 24x22 타일 — 화면(10x9 가시)보다 커서 카메라가 의미를 가진다. 가장자리는 나무로 둘러 막는다(연못 분지).
// The map is 24x22 tiles — bigger than the 10x9 visible screen so a camera matters. A tree border rings the pond basin.
//
// Key identifiers: 맵너비/맵높이=mapWidth/mapHeight, 맵타일들=mapTiles (row-major flat int array),
// 타일에서=tileAt (id at x,y with out-of-bounds = tree wall).
export let 맵너비 = 24
export let 맵높이 = 22
export type 타일종류 = 0 | 1 | 2 | 3 | 4 | 5
export let 타일_풀: 타일종류 = 0
export let 타일_키큰풀: 타일종류 = 1
export let 타일_길: 타일종류 = 2
export let 타일_물: 타일종류 = 3
export let 타일_나무: 타일종류 = 4
export let 타일_꽃: 타일종류 = 5
// 24x22 = 528 타일, 행 우선(row-major). 한 행 = 24개. 나무(4) 테두리, 가운데 물웅덩이(3), 키큰풀밭(1), 길(2)·꽃(5).
// 24x22 = 528 tiles, row-major (one row = 24 entries). Tree (4) border, a central water pool (3), tall-grass meadows (1),
// path (2), flowers (5). This is a hand-authored original layout; it does not change at runtime (pure data).
export function 연못타일들() -> Array<int> {
[
4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,
4,0,0,0,5,0,0,1,1,1,1,0,0,0,0,5,0,0,0,0,0,0,0,4,
4,0,0,0,0,0,0,1,1,1,1,0,0,2,2,2,2,2,2,0,0,5,0,4,
4,0,5,0,0,0,0,1,1,1,1,0,0,2,0,0,0,0,2,0,0,0,0,4,
4,0,0,0,0,0,0,0,1,1,0,0,0,2,0,0,0,0,2,0,1,1,0,4,
4,0,0,2,2,2,2,2,2,2,2,2,2,2,0,0,5,0,2,0,1,1,0,4,
4,5,0,2,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,4,
4,0,0,2,0,0,3,3,3,3,3,0,0,0,0,0,0,0,2,2,2,2,2,4,
4,0,0,2,0,3,3,3,3,3,3,3,0,0,5,0,0,0,0,0,0,0,2,4,
4,0,0,2,0,3,3,3,3,3,3,3,3,0,0,0,0,1,1,1,0,0,2,4,
4,0,5,2,0,3,3,3,3,3,3,3,3,0,0,0,0,1,1,1,0,0,2,4,
4,0,0,2,0,3,3,3,3,3,3,3,0,0,0,0,0,1,1,1,0,0,2,4,
4,0,0,2,0,0,3,3,3,3,3,0,0,0,5,0,0,0,0,0,0,0,2,4,
4,0,0,2,2,2,0,0,0,0,0,0,0,2,2,2,2,2,2,2,2,2,2,4,
4,0,5,0,0,2,0,0,5,0,0,0,0,2,0,0,0,0,0,0,0,0,0,4,
4,0,0,0,0,2,2,2,2,2,2,2,2,2,0,0,5,0,0,5,0,0,0,4,
4,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,4,
4,1,1,1,0,0,5,0,0,0,1,1,1,0,0,0,0,0,0,0,1,1,1,4,
4,1,1,1,0,0,0,0,0,0,1,1,1,0,0,5,0,0,0,0,1,1,1,4,
4,0,1,1,0,0,0,5,0,0,1,1,0,0,0,0,0,0,0,0,0,1,0,4,
4,0,0,0,0,5,0,0,0,0,0,0,0,0,0,5,0,0,0,5,0,0,0,4,
4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4
]
}
// 심층(deep): 연못 너머의 두 번째 생물군계. 어둡고 트인 동굴·깊은 물. 더 강한 야생이 산다. 같은 24x22 격자라 카메라·렌더를 그대로 쓴다(오리지널).
// The deep: a second biome past the pond, a dark open cavern with deep water and stronger wilds. Same 24x22 grid so camera/render are reused. Original.
export function 심층타일들() -> Array<int> {
[
4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,
4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,
4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,0,0,4,
4,0,0,0,0,0,0,0,0,3,3,3,0,0,0,0,0,0,0,1,1,0,0,4,
4,0,0,0,0,0,0,0,3,3,3,3,3,0,0,0,0,0,0,1,0,0,0,4,
4,0,0,0,0,0,0,0,3,3,3,3,3,0,0,0,0,0,0,0,0,0,0,4,
4,0,0,4,0,0,0,0,0,3,3,3,0,0,0,0,4,0,0,0,0,0,0,4,
4,0,0,4,0,0,0,0,0,0,0,0,0,0,0,0,4,0,0,0,0,0,0,4,
4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,
4,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,
4,0,1,1,0,0,0,0,0,0,5,0,0,0,0,0,0,0,0,0,0,0,0,4,
4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,
4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,4,0,4,
4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,0,0,4,
4,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,
4,0,0,0,0,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,
4,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,0,0,0,0,0,0,0,4,
4,0,0,0,0,0,0,0,0,0,0,0,3,3,3,3,3,0,0,0,0,0,0,4,
4,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,0,0,0,5,0,0,0,4,
4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,
4,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,4,
4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,4
]
}
// 맵 아이디로 타일 격자를 고른다. "deep"=심층, 그 외=연못. / Pick the tile grid by map id ("deep" -> the deep, else the pond).
export function 맵타일들(맵아이디: string) -> Array<int> {
if 맵아이디 == "deep" { 심층타일들() } else { 연못타일들() }
}
// ── 워프(맵 전이) ─────────────────────────────────────────────────────────────────────────────────
// 격자에 특수 타일을 박지 않고 위치 기반으로 둔다(엔티티와 같은 방식). 그 칸을 밟으면 목적 맵·목적 좌표로 옮긴다. 오리지널·결정론.
// Warps are position-based (like entities), not special tiles. Stepping onto one moves the player to the target map + position. Original, deterministic.
export type 워프 = { 엑스: int, 와이: int, 목적맵: string, 목적엑스: int, 목적와이: int }
export function 워프목록(맵아이디: string) -> Array<워프> {
if 맵아이디 == "deep" {
// 심층의 입구(좌상단) → 연못으로 복귀. / the deep's entrance back to the pond.
[ { 엑스: 3, 와이: 3, 목적맵: "pond", 목적엑스: 12, 목적와이: 17 } ]
} else {
// 연못 남쪽의 통로 → 심층으로. / a passage in the south of the pond down into the deep.
[ { 엑스: 12, 와이: 18, 목적맵: "deep", 목적엑스: 3, 목적와이: 4 } ]
}
}
// (맵,x,y)에 워프가 있으면 그 워프를, 없으면 목적맵="" 인 더미를 돌려준다. / the warp at (map,x,y) or a dummy (목적맵 "") if none.
export function 워프에서(맵아이디: string, 엑스: int, 와이: int) -> 워프 {
let mut 결과 = { 엑스: -1, 와이: -1, 목적맵: "", 목적엑스: 0, 목적와이: 0 }
for w in 워프목록(맵아이디) {
if w.엑스 == 엑스 && w.와이 == 와이 { 결과 = w }
}
결과
}
function 타일파싱(값: int) -> 타일종류 {
if 값 == 타일_풀 { return 타일_풀 }
if 값 == 타일_키큰풀 { return 타일_키큰풀 }
if 값 == 타일_길 { return 타일_길 }
if 값 == 타일_물 { return 타일_물 }
if 값 == 타일_꽃 { return 타일_꽃 }
타일_나무
}
// (x,y)의 타일 아이디. 범위를 벗어나면 나무 벽(4)으로 막아 카메라 클램프·이동 경계를 안전하게 한다.
// The tile id at (x,y). Out of bounds returns the tree wall (4) so camera clamping and movement bounds stay safe.
export function 타일에서(타일들: Array<int>, 엑스: int, 와이: int) -> 타일종류 {
if 엑스 < 0 || 와이 < 0 || 엑스 >= 맵너비 || 와이 >= 맵높이 { return 타일_나무 }
타일파싱(타일들[와이 * 맵너비 + 엑스])
}
// ===== data/palettes.tpz =====
// 연못 바이옴 4색 팔레트 (GBC풍, 제니앱 흑백 위 한 톤 — §1.2 녹/모래/물).
// 토파즈는 그릴 수 없다: 여기서는 색 데이터(헥스 문자열)만 내보내고, JS 셸이 그 색으로 픽셀을 칠한다.
// Pond-biome 4-color palette (GBC-style, the §1.2 greens/sand/water). Topaz cannot draw — this module only
// exports color DATA (hex strings); the JS device shell paints pixels with these colors.
//
// 하우스룰(data): 오리지널 콘텐츠 데이터만(순수 함수·불변 바인딩). 엔진·Host 안 건드린다.
// House rule (data): original content data only (pure functions / immutable bindings); never touches engine or Host.
//
// 4색은 어두움→밝음 순서(인덱스 0..3): 0=깊은 물/그림자, 1=중간 녹/이끼, 2=밝은 풀/모래, 3=배경(연한 모래빛).
// The four colors run dark->light (index 0..3): 0 = deep water/shadow, 1 = mid green/moss,
// 2 = light grass/sand, 3 = background (pale sand). The JS maps every tile/sprite dot to one of these four.
//
// Key identifiers: 연못팔레트=pondPalette (returns the four hex colors, comma-joined).
// 연못 바이옴의 정규 4색을 콤마로 이은 문자열로 돌려준다(드로우리스트 PAL 줄에 그대로 들어간다).
// The pond biome's canonical four colors, comma-joined (drops straight into the draw-list PAL line).
export function 연못팔레트() -> string {
// 0 깊은물·그림자 / 1 이끼녹 / 2 밝은풀 / 3 연한모래(배경)
// 0 deep water·shadow / 1 moss green / 2 light grass / 3 pale sand (background)
"#1B3A2E,#3E6B4F,#8FB97A,#E8E4C9"
}
// ===== data/species.tpz =====
// 원시 연못의 오리지널 미생물 종(species). 전부 100% 오리지널(닌텐도/포켓몬 자산 0).
// P1은 조우용 최소 필드만 가졌다. P2는 배틀에 필요한 스탯(ATK/DEF/SPD 기반치)·기술목록·플레이어 시작종을 더한다.
// 풀 종 시스템(게놈·진화 계통)은 P4+. / Original pond microbe SPECIES — 100% original (zero Nintendo/Pokemon assets).
// P1 carried only what an encounter needs; P2 adds battle stats (base ATK/DEF/SPD), a move list, and the player's starter.
//
// 하우스룰(data): 오리지널 콘텐츠만(순수 함수·불변 바인딩). 엔진·Host 안 건드린다. now_millis·파일IO·비트연산 금지.
// House rule (data): original content only (pure functions / immutable bindings); never touches engine/Host; no now_millis/file-IO/bitwise.
//
// ── 종 레코드 / Species record ───────────────────────────────────────────────────────────────────
// 아이디=id · 이름=name · 스프라이트=battle-sprite tile id · 최소/최대레벨=base level band · 기본체력=base HP
// 기본공격/기본방어/기본속도=base ATK/DEF/SPD (레벨로 성장) · 속성=primary type (0..3, types.tpz)
// 기술1..기술4=move ids (data.moves; 0 = empty slot)
//
// ── 미생물 4종(원시 연못) / four pond microbes ───────────────────────────────────────────────────
// 1 또랑이 점액(0) 흔한 아메바형, 느릿느릿. battle tile 1. 기술: 몸통박치기·점액뿜기
// 2 빛이끼 광합성(1) 이끼 군체, 빛을 머금음. battle tile 2. 기술: 몸통박치기·흡수광선
// 3 가시포자 포자(2) 가시 돋친 포자, 드묾. battle tile 3. 기술: 몸통박치기·포자살포·가시난사
// 4 시조 아메바 점액(0) 플레이어 시작종. 연구원이 건넨 원시 아메바. battle tile 4. 기술: 몸통박치기·점액뿜기·산성거품
// 1 Ttorang-i(slime amoeba) · 2 Bit-ikki(photo colony) · 3 Gasi-poja(spiky spore) · 4 Sijo-Amoeba(player starter, slime).
// Names/types/stats/art all original.
//
// Key identifiers: 종=Species, 종목록=speciesList, 종찾기=findSpecies, 속성이름=typeName, 시작종아이디=starterSpeciesId.
import data.types { 효과배율2 }
export type 종 = {
아이디: int,
이름: string,
스프라이트: int,
최소레벨: int,
최대레벨: int,
기본체력: int,
기본공격: int,
기본방어: int,
기본속도: int,
속성: int,
기술1: int,
기술2: int,
기술3: int,
기술4: int
}
// 사람이 읽을 속성 이름. 정규 차트는 data.types 에 있지만(효과배율2 import로 의존 명시), 이름은 여기서도 노출한다.
// drawlist/encounter 가 species.속성이름 을 부르므로 호환을 위해 유지하되 types 와 같은 한국어를 쓴다.
// Human-readable type name. The canonical chart lives in data.types (imported as 효과배율2 to make the dep explicit);
// this name stays for backward-compat (drawlist/encounter call species.속성이름) and matches data.types exactly.
export function 속성이름(속성: int) -> string {
if 속성 == 0 { return "점액" }
if 속성 == 1 { return "광합성" }
if 속성 == 2 { return "포자" }
if 속성 == 3 { return "결정" }
"무속성"
}
// 플레이어 시작종 아이디(시조 아메바). init 때 파티 첫 자리에 들어간다. / The player's starter species id (시조 아메바).
export function 시작종아이디() -> int {
4
}
// 연못 종 목록(아이디로 참조). 4번 시조 아메바는 조우 테이블엔 없다(시작 지급 전용). / Pond species list (referenced by id).
export function 종목록() -> Array<종> {
[
{ 아이디: 1, 이름: "또랑이", 스프라이트: 1, 최소레벨: 2, 최대레벨: 4, 기본체력: 16, 기본공격: 7, 기본방어: 6, 기본속도: 5, 속성: 0, 기술1: 1, 기술2: 2, 기술3: 0, 기술4: 0 },
{ 아이디: 2, 이름: "빛이끼", 스프라이트: 2, 최소레벨: 3, 최대레벨: 5, 기본체력: 18, 기본공격: 6, 기본방어: 7, 기본속도: 4, 속성: 1, 기술1: 1, 기술2: 3, 기술3: 0, 기술4: 0 },
{ 아이디: 3, 이름: "가시포자", 스프라이트: 3, 최소레벨: 4, 최대레벨: 6, 기본체력: 14, 기본공격: 8, 기본방어: 5, 기본속도: 7, 속성: 2, 기술1: 1, 기술2: 4, 기술3: 8, 기술4: 0 },
{ 아이디: 4, 이름: "시조 아메바", 스프라이트: 4, 최소레벨: 5, 최대레벨: 5, 기본체력: 20, 기본공격: 8, 기본방어: 6, 기본속도: 6, 속성: 0, 기술1: 1, 기술2: 2, 기술3: 6, 기술4: 0 },
// 심층 전용 종(레벨대가 높고 더 단단/빠름). 스프라이트 5~7 은 JS SPECIES 색표에 대응. / deep-only species (higher band, tankier/faster); sprites 5-7 map to the JS color table.
{ 아이디: 5, 이름: "심해포자", 스프라이트: 5, 최소레벨: 8, 최대레벨: 11, 기본체력: 24, 기본공격: 9, 기본방어: 9, 기본속도: 5, 속성: 1, 기술1: 1, 기술2: 3, 기술3: 8, 기술4: 0 },
{ 아이디: 6, 이름: "발광충", 스프라이트: 6, 최소레벨: 9, 최대레벨: 12, 기본체력: 19, 기본공격: 11, 기본방어: 6, 기본속도: 9, 속성: 3, 기술1: 1, 기술2: 4, 기술3: 2, 기술4: 0 },
{ 아이디: 7, 이름: "암각체", 스프라이트: 7, 최소레벨: 10, 최대레벨: 13, 기본체력: 28, 기본공격: 8, 기본방어: 12, 기본속도: 3, 속성: 2, 기술1: 1, 기술2: 2, 기술3: 8, 기술4: 0 },
// 심연군주: 심층 가장 깊은 곳의 단 하나뿐인 보스(엔드게임 절정). 모든 면에서 압도적 — 충분히 키운 계통이라야 이긴다. / the abyss lord: the deep's lone boss, the endgame climax.
{ 아이디: 8, 이름: "심연군주", 스프라이트: 8, 최소레벨: 15, 최대레벨: 15, 기본체력: 44, 기본공격: 13, 기본방어: 11, 기본속도: 8, 속성: 1, 기술1: 1, 기술2: 3, 기술3: 4, 기술4: 8 }
]
}
// 아이디로 종을 찾는다(없으면 첫 종으로 안전 복귀 — 빈 배열 인덱싱 방지). / Find a species by id (falls back to the first).
export function 종찾기(아이디: int) -> 종 {
let 목록 = 종목록()
let mut 결과 = 목록[0]
for 종항목 in 목록 {
if 종항목.아이디 == 아이디 { 결과 = 종항목 }
}
결과
}
// ===== data/encounters.tpz =====
// 원시 연못의 조우 테이블(키큰풀에 어떤 종이 어떤 상대빈도로 나오는가) — 데이터 주도. 오리지널.
// Pond ENCOUNTER table: which species appear in tall-grass and at what relative rate — data-driven, original.
//
// 하우스룰(data): 오리지널 콘텐츠만(순수 함수·불변 바인딩). 엔진·Host 안 건드린다. / original content only, pure; no engine/Host.
//
// 두 가지를 정한다 / Two things:
// 1) 조우율(0..99): 키큰풀 한 칸을 밟을 때 조우가 일어날 확률 게이트. 굴림 < 조우율 이면 조우. (튜닝 가능)
// Encounter rate (0..99): the probability gate when stepping onto a tall-grass tile. roll < rate => an encounter.
// 2) 가중 테이블: (종아이디, 가중치). 가중치 합으로 종을 뽑는다(흔함=큰 가중치). 또랑이 흔함·빛이끼 보통·가시포자 드묾.
// Weighted table: (species id, weight). Pick by cumulative weight (common = larger weight).
//
// Key identifiers: 조우율=encounterRate, 조우항목=EncounterEntry, 조우테이블=encounterTable,
// 가중합=totalWeight, 종뽑기=pickSpeciesId.
export type 조우항목 = { 종아이디: int, 가중치: int }
// 키큰풀 한 칸당 조우 확률(%). 30 = 30%. 결정론적: 같은 칸·같은 스텝·같은 시드 → 같은 게이트 결과.
// Per-tall-grass-tile encounter chance (%). 30 = 30%. Deterministic: same tile/step/seed -> same gate outcome.
export function 조우율() -> int {
30
}
// 연못 키큰풀 가중 테이블. 가중치 합 = 6+3+1 = 10. 또랑이 60% · 빛이끼 30% · 가시포자 10%(드뭄).
// The pond tall-grass weighted table. Total weight 10: Ttorang-i 60% / Bit-ikki 30% / Gasi-poja 10% (rare).
export function 조우테이블() -> Array<조우항목> {
[
{ 종아이디: 1, 가중치: 6 },
{ 종아이디: 2, 가중치: 3 },
{ 종아이디: 3, 가중치: 1 }
]
}
// 가중치 합. / Sum of the weights.
export function 가중합() -> int {
let mut 합 = 0
for 항목 in 조우테이블() {
합 = 합 + 항목.가중치
}
합
}
// 0..(가중합-1) 굴림값에서 종 아이디를 뽑는다(누적 가중치). 굴림은 코어가 PRNG로 만든다(여기는 순수 선택 로직).
// Pick a species id from a 0..(totalWeight-1) roll via cumulative weights. The roll comes from the core PRNG
// (this module is pure selection logic). 범위 밖은 마지막 종으로 안전 복귀. / out-of-range falls back to the last species.
export function 종뽑기(굴림: int) -> int {
let 테이블 = 조우테이블()
let mut 누적 = 0
let mut 결과 = 테이블[테이블.length - 1].종아이디
let mut 정해짐 = false
for 항목 in 테이블 {
누적 = 누적 + 항목.가중치
if !정해짐 && 굴림 < 누적 {
결과 = 항목.종아이디
정해짐 = true
}
}
결과
}
// ===== data/entities.tpz =====
// 맵 위 엔티티(상호작용 대상). 랜덤 조우 대신 "보이는 대상과 마주 서서 A로 상호작용"한다.
// 종류 0 = 야생 미생물(마주 서서 A → 배틀), 종류 1 = NPC(마주 서면 한마디). 좌표는 맵 타일.
// On-map entities the player interacts with. No random encounters: you SEE a target, face it, and press A.
// kind 0 = wild microbe (face + A -> battle), kind 1 = NPC (face -> one line). Coordinates are map tiles.
//
// 하우스룰(data): 오리지널 콘텐츠만(순수 함수·불변). 엔진·Host 안 건드린다. / original content only, pure; no engine/Host.
// 불변식: 한 칸에 엔티티는 최대 하나. 통행 불가 타일(물 3·나무 4)·시작칸(3,5) 위에는 두지 않는다. / one entity per tile; never on water/tree/spawn.
// 대사는 파이프(|)를 포함하지 않는다 — 드로우리스트 TXT 토큰으로 나가기 때문. / dialogue must contain no pipe (it ships in a TXT token).
//
// Key identifiers: 엔티티=Entity, 엔티티목록=entityList, 엔티티에서=entityAt, 엔티티있음=hasEntity, 없는엔티티=noEntity.
export let 엔티티_없음 = -1
export let 엔티티_야생 = 0
export let 엔티티_NPC = 1
export type 엔티티 = {
아이디: int,
엑스: int,
와이: int,
종류: int, // 0 야생 / 1 NPC
종아이디: int, // 야생일 때 종 아이디(없으면 0) / wild species id (0 otherwise)
레벨: int, // 야생일 때 레벨(없으면 0) / wild level (0 otherwise)
대사: string // NPC 한마디(야생이면 "") / NPC line ("" for wild)
}
// "없음"을 뜻하는 더미 엔티티(종류 -1). 엔티티에서가 못 찾으면 이걸 돌려준다. / The "none" sentinel (kind -1) returned when no entity is found.
export function 없는엔티티() -> 엔티티 {
{ 아이디: 0, 엑스: -1, 와이: -1, 종류: 엔티티_없음, 종아이디: 0, 레벨: 0, 대사: "" }
}
// 시작 맵(연못)의 엔티티 배치. 시작칸(3,5)·초기 방향(아래)을 고려해 첫 대상을 바로 코앞에 둔다(10초 안에 루프 발견).
// 시작칸 한 칸 아래(3,6)에 약한 야생을 둬 스폰하자마자 A 한 번으로 첫 배틀이 열린다. 오른쪽 길에 안내 NPC, 그 너머로 더 강한 야생들.
// Pond entity layout. Given spawn (3,5) facing DOWN, the first target sits one tile below so a single A press opens the first battle.
// A guide NPC stands on the path to the right; stronger wilds lie beyond. Authored, original, deterministic.
export function 엔티티목록(맵아이디: string) -> Array<엔티티> {
if 맵아이디 == "deep" {
// 심층: 더 강한 야생(레벨 9~11) + 입구 근처 휴식 NPC. 아이디는 연못(1~9)과 겹치지 않게 10번대(처치셋은 맵 공용). / the deep: stronger wilds + a rest NPC; ids 10+ (cleared set is shared).
[
{ 아이디: 10, 엑스: 5, 와이: 5, 종류: 엔티티_NPC, 종아이디: 0, 레벨: 0, 대사: "심층입니다. A를 누르면 쉬어 회복합니다. 가장 깊은 곳엔 심연군주가 있습니다 — 충분히 키운 계통이라야 마주할 수 있습니다." },
// 심층 입구(스폰 3,4)에서 가까운 순으로 레벨이 오른다. 가장 가까운 12(2,9)=Lv7 은 덜 자란 플레이어도 이겨 한 번에 여러 레벨 올라 진행이 막히지 않는다.
// Levels rise by distance from the deep entrance (3,4): the nearest, id 12, is a Lv7 entry an under-leveled player can win (one win = several levels), so progression never stalls.
{ 아이디: 11, 엑스: 19, 와이: 3, 종류: 엔티티_야생, 종아이디: 5, 레벨: 11, 대사: "" },
{ 아이디: 12, 엑스: 2, 와이: 9, 종류: 엔티티_야생, 종아이디: 6, 레벨: 7, 대사: "" },
{ 아이디: 13, 엑스: 7, 와이: 14, 종류: 엔티티_야생, 종아이디: 7, 레벨: 9, 대사: "" },
// 심연군주(보스): 심층 가장 깊은 곳(11,19). 엔드게임 절정 — 잡거나 쓰러뜨리면 그 자리에서 사라진다(처치셋). / the abyss-lord boss, the endgame climax.
{ 아이디: 14, 엑스: 11, 와이: 19, 종류: 엔티티_야생, 종아이디: 8, 레벨: 15, 대사: "" }
]
} else {
[
// 시작 근처(스폰 아래·오른쪽 길): 첫 조우 + 안내. / near spawn: the first encounter + the guide.
{ 아이디: 1, 엑스: 3, 와이: 6, 종류: 엔티티_야생, 종아이디: 1, 레벨: 3, 대사: "" },
{ 아이디: 2, 엑스: 5, 와이: 5, 종류: 엔티티_NPC, 종아이디: 0, 레벨: 0, 대사: "여기서 A를 누르면 동료들이 쉬어 회복합니다. 마주 선 미생물에게 A를 누르면 맞붙습니다." },
{ 아이디: 3, 엑스: 6, 와이: 6, 종류: 엔티티_야생, 종아이디: 2, 레벨: 4, 대사: "" },
{ 아이디: 4, 엑스: 9, 와이: 6, 종류: 엔티티_야생, 종아이디: 3, 레벨: 6, 대사: "" },
// 연못 곳곳: 돌아다니며 만나는 더 강한 야생들(진화까지 충분한 전투). 모두 통행 가능한 칸. / spread across the pond: stronger wilds to fuel the climb to evolution.
{ 아이디: 5, 엑스: 8, 와이: 13, 종류: 엔티티_야생, 종아이디: 1, 레벨: 5, 대사: "" },
{ 아이디: 6, 엑스: 2, 와이: 16, 종류: 엔티티_야생, 종아이디: 2, 레벨: 6, 대사: "" },
{ 아이디: 7, 엑스: 11, 와이: 17, 종류: 엔티티_야생, 종아이디: 3, 레벨: 8, 대사: "" },
{ 아이디: 8, 엑스: 20, 와이: 5, 종류: 엔티티_야생, 종아이디: 1, 레벨: 7, 대사: "" },
{ 아이디: 9, 엑스: 13, 와이: 14, 종류: 엔티티_NPC, 종아이디: 0, 레벨: 0, 대사: "남쪽 통로로 내려가면 더 깊은 심층입니다. 키운 동료가 충분히 자라면 천천히, 그리고 또 진화하며 계통이 깊어집니다." }
]
}
}
// (맵,엑스,와이) 칸의 엔티티. 없으면 없는엔티티(종류 -1). 한 칸에 하나라는 불변식 덕에 마지막 일치를 그대로 쓴다.
// The entity at (map,x,y), or 없는엔티티 (kind -1) when empty. One-per-tile invariant, so the last match is THE match.
export function 엔티티에서(맵아이디: string, 엑스: int, 와이: int) -> 엔티티 {
let mut 결과 = 없는엔티티()
for 이 in 엔티티목록(맵아이디) {
if 이.엑스 == 엑스 && 이.와이 == 와이 { 결과 = 이 }
}
결과
}
// 그 칸에 엔티티가 있는가(이동 충돌 + 상호작용 판정용). / Is there an entity on that tile (movement collision + interaction).
export function 엔티티있음(맵아이디: string, 엑스: int, 와이: int) -> bool {
엔티티에서(맵아이디, 엑스, 와이).종류 != 엔티티_없음
}
// 아이디가 처치 목록에 있는가. / Is the id in the cleared list.
export function 처치포함(처치됨: Array<int>, 아이디: int) -> bool {
let mut 있음 = false
for x in 처치됨 { if x == 아이디 { 있음 = true } }
있음
}
// 처치 안 된 활성 엔티티만 돌려준다(처치됐으면 없는엔티티). 이동 충돌·상호작용·렌더가 모두 이걸 본다 → 잡거나 이긴 야생은 사라진다.
// Returns only an ACTIVE (not-cleared) entity, else 없는엔티티. Movement/interaction/render all use this, so a caught/defeated wild vanishes.
export function 활성엔티티에서(맵아이디: string, 엑스: int, 와이: int, 처치됨: Array<int>) -> 엔티티 {
let 이 = 엔티티에서(맵아이디, 엑스, 와이)
if 이.종류 != 엔티티_없음 && 처치포함(처치됨, 이.아이디) { 없는엔티티() } else { 이 }
}
// 그 칸에 활성(처치 안 된) 엔티티가 있는가. / Is there an ACTIVE entity on that tile.
export function 활성엔티티있음(맵아이디: string, 엑스: int, 와이: int, 처치됨: Array<int>) -> bool {
활성엔티티에서(맵아이디, 엑스, 와이, 처치됨).종류 != 엔티티_없음
}
// ===== data/types.tpz =====
// 오리지널 속성(type) 차트 — 원시 연못의 미생물 4속성과 그 상성. P2 배틀의 전략 축.
// 100% 오리지널(닌텐도/포켓몬 자산 0). P1의 점액/광합성/포자에 결정(crystal)을 더해 작고 순환적인 상성을 이룬다.
// Original TYPE chart — four pond-microbe types and their effectiveness. The strategic axis of the P2 battle.
// 100% original (zero Nintendo/Pokemon assets). P1 used 점액/광합성/포자; we add 결정(crystal) for a small, cyclic chart.
//
// 하우스룰(data): 오리지널 콘텐츠만(순수 함수·불변 바인딩). 엔진·Host 안 건드린다. now_millis·파일IO·비트연산 금지.
// House rule (data): original content only (pure functions / immutable bindings); never touches engine/Host; no now_millis/file-IO/bitwise.
//
// ── 속성 코드 / type codes ──────────────────────────────────────────────────────────────────────
// 0 점액(slime) · 1 광합성(photo) · 2 포자(spore) · 3 결정(crystal)
//
// ── 상성 순환(작고 읽기 쉽게 — 전략이 의미 있도록) / cyclic effectiveness (small + legible so strategy matters) ──
// 점액 → 광합성 강함(x2) · 점액 → 포자 약함(x0.5) (점액이 이끼를 덮는다 / 포자엔 약하다)
// 광합성 → 포자 강함(x2) · 광합성 → 결정 약함(x0.5) (빛이 포자를 말린다 / 결정엔 약하다)
// 포자 → 점액 강함(x2) · 포자 → 광합성 약함(x0.5) (포자가 점액을 감염시킨다 / 빛엔 약하다)
// 결정 → 점액 강함(x2) · 결정 → 포자 약함(x0.5) (결정이 점액을 가른다 / 포자엔 약하다)
// 그 외 모든 짝 = 보통(x1). / every other pairing = neutral (x1).
// slime>photo, photo>spore, spore>slime — a rock/paper/scissors core; crystal>slime, weak to photo — a fourth wrinkle.
//
// ── 부동소수 없이 x0.5 표현 / x0.5 WITHOUT floats ─────────────────────────────────────────────────
// 효과배율2 = (실제 배율) × 2 로 정수화한다: x2 → 4, x1 → 2, x0.5 → 1. 데미지는 base × 효과배율2 / 2 로 정수 나눗셈.
// effectiveness is stored ×2 as an int: x2 -> 4, x1 -> 2, x0.5 -> 1. Damage = base * eff2 / 2 (integer division — never a float).
//
// Key identifiers: 속성수=typeCount, 속성이름=typeName, 효과배율2=effectiveness2 (the ×2-scaled multiplier).
// 속성 개수(0..3). / Number of types (0..3).
export function 속성수() -> int {
4
}
// 사람이 읽을 속성 이름(배틀 UI·디버그). 알 수 없으면 "무속성". species.속성이름 과 일관된 한국어.
// Human-readable type name (battle UI / debug). Unknown -> "무속성". Consistent Korean with species.속성이름.
export function 속성이름(속성: int) -> string {
if 속성 == 0 { return "점액" }
if 속성 == 1 { return "광합성" }
if 속성 == 2 { return "포자" }
if 속성 == 3 { return "결정" }
"무속성"
}
// 공격속성 → 방어속성 의 효과를 ×2 스케일 정수로 돌려준다(부동소수 없음).
// 4(=x2) 강함 · 2(=x1) 보통 · 1(=x0.5) 약함. 표에 없는 짝은 보통(2).
// Effectiveness of attacking-type vs defending-type as a ×2-scaled int (NO floats):
// 4 (=x2) super-effective · 2 (=x1) neutral · 1 (=x0.5) not-very-effective. Any pairing not listed = neutral (2).
export function 효과배율2(공격속성: int, 방어속성: int) -> int {
// 점액(0)
if 공격속성 == 0 && 방어속성 == 1 { return 4 } // 점액 → 광합성 x2
if 공격속성 == 0 && 방어속성 == 2 { return 1 } // 점액 → 포자 x0.5
// 광합성(1)
if 공격속성 == 1 && 방어속성 == 2 { return 4 } // 광합성 → 포자 x2
if 공격속성 == 1 && 방어속성 == 3 { return 1 } // 광합성 → 결정 x0.5
// 포자(2)
if 공격속성 == 2 && 방어속성 == 0 { return 4 } // 포자 → 점액 x2
if 공격속성 == 2 && 방어속성 == 1 { return 1 } // 포자 → 광합성 x0.5
// 결정(3)
if 공격속성 == 3 && 방어속성 == 0 { return 4 } // 결정 → 점액 x2
if 공격속성 == 3 && 방어속성 == 2 { return 1 } // 결정 → 포자 x0.5
2 // 그 외 = 보통(x1) / everything else = neutral
}
// ===== data/moves.tpz =====
// 오리지널 기술(move) 데이터 — P2 배틀이 쓰는 작은 기술 묶음. 전부 오리지널(닌텐도/포켓몬 자산 0).
// Original MOVE data — the small move set the P2 battle uses. All original (zero Nintendo/Pokemon assets).
//
// 하우스룰(data): 오리지널 콘텐츠만(순수 함수·불변 바인딩). 엔진·Host 안 건드린다. now_millis·파일IO·비트연산 금지.
// House rule (data): original content only (pure functions / immutable bindings); never touches engine/Host; no now_millis/file-IO/bitwise.
//
// ── 기술 레코드 / move record ────────────────────────────────────────────────────────────────────
// 아이디=id (안정 정수 키) · 이름=name (오리지널 한국어) · 속성=type (types.tpz 코드 0..3)
// 위력=power (데미지 공식 base) · 명중=accuracy (0..100, 명중 굴림 < 명중 이면 적중) · 기력=PP/energy (남은 사용 횟수)
// power=damage base · accuracy 0..100 (a 0..99 roll < accuracy hits) · PP = remaining uses.
//
// 위력 0 인 기술은 없음(P2는 전부 공격기). 상태기·변화기는 후속 슬라이스. / No 0-power moves in P2 (all are damaging); status moves are later.
//
// ── 원시 연못 기술 8종 / eight pond moves ──────────────────────────────────────────────────────────
// 1 몸통박치기 무속성 위력35 명중95 PP35 — 어떤 미생물이나 쓰는 기본 타격. / generic tackle (typeless = 무속성 99).
// 2 점액뿜기 점액 위력40 명중100 PP25 — 점성 점액을 끼얹는다.
// 3 흡수광선 광합성 위력45 명중95 PP20 — 빛을 모아 쏜다.
// 4 포자살포 포자 위력40 명중100 PP25 — 가시 포자를 흩뿌린다.
// 5 결정파편 결정 위력45 명중95 PP20 — 결정 조각을 박는다.
// 6 산성거품 점액 위력55 명중85 PP15 — 강한 산성 거품(고위력·저명중).
// 7 광합폭발 광합성 위력60 명중80 PP10 — 모은 빛을 터뜨린다(고위력·저명중).
// 8 가시난사 포자 위력55 명중85 PP15 — 가시를 난사한다(고위력·저명중).
// All names/types/powers original; numbers tuned for a short, readable P2 battle.
//
// Key identifiers: 기술=Move, 기술목록=moveList, 기술찾기=findMove, 무속성코드=typelessCode.
export type 기술 = {
아이디: int,
이름: string,
속성: int,
위력: int,
명중: int,
기력: int
}
// 무속성 코드(타입 차트 밖, 항상 보통 배율). 몸통박치기 같은 범용기에 쓴다. / Typeless code (outside the chart -> always neutral).
export function 무속성코드() -> int {
99
}
// 연못 기술 목록(아이디로 참조 — 인덱스 아님, 나중에 늘어도 안정). / Pond move list (referenced by id, not index).
export function 기술목록() -> Array<기술> {
[
{ 아이디: 1, 이름: "몸통박치기", 속성: 99, 위력: 35, 명중: 95, 기력: 35 },
{ 아이디: 2, 이름: "점액뿜기", 속성: 0, 위력: 40, 명중: 100, 기력: 25 },
{ 아이디: 3, 이름: "흡수광선", 속성: 1, 위력: 45, 명중: 95, 기력: 20 },
{ 아이디: 4, 이름: "포자살포", 속성: 2, 위력: 40, 명중: 100, 기력: 25 },
{ 아이디: 5, 이름: "결정파편", 속성: 3, 위력: 45, 명중: 95, 기력: 20 },
{ 아이디: 6, 이름: "산성거품", 속성: 0, 위력: 55, 명중: 85, 기력: 15 },
{ 아이디: 7, 이름: "광합폭발", 속성: 1, 위력: 60, 명중: 80, 기력: 10 },
{ 아이디: 8, 이름: "가시난사", 속성: 2, 위력: 55, 명중: 85, 기력: 15 }
]
}
// 아이디로 기술을 찾는다(없으면 몸통박치기로 안전 복귀 — 빈/오염 슬롯 방어). / Find a move by id (falls back to 몸통박치기 / id 1).
export function 기술찾기(아이디: int) -> 기술 {
let 목록 = 기술목록()
let mut 결과 = 목록[0]
for 항목 in 목록 {
if 항목.아이디 == 아이디 { 결과 = 항목 }
}
결과
}
// ===== data/items.tpz =====
// 아이템 데이터(최소 — 포획에 필요한 만큼만). P3은 스포어볼(포획 도구) 하나뿐. 전부 오리지널.
// 풀 가방·상점·회복약은 후속 슬라이스다. 여기서는 시작 재고와 볼 등급만 데이터로 노출한다(데이터 주도).
// Item data (minimal — only what capture needs). P3 ships ONE item: the spore-ball (capture tool). All original.
// The full bag/shop/healing items are a later slice; here we expose only the start stock + ball grade as data.
//
// 하우스룰(data): 오리지널 콘텐츠만(순수 함수·불변 바인딩). 엔진·Host 안 건드린다. now_millis·파일IO·비트연산 금지.
// House rule (data): original content only (pure functions / immutable bindings); never touches engine/Host; no now_millis/file-IO/bitwise.
//
// ── 스포어볼 / spore-ball ────────────────────────────────────────────────────────────────────────
// 야생 미생물을 가두는 포자 캡슐. 던지면 1개 소모된다. init에서 소량 지급(시작 재고). 0개면 CATCH 불가.
// A spore capsule that traps a wild microbe. One is consumed per throw. A small stock is granted at init; at 0, CATCH is unavailable.
//
// Key identifiers: 시작스포어볼=startingSporeBalls, 스포어볼등급=sporeBallGrade.
// init에서 지급하는 스포어볼 시작 재고. 작게(연구원이 건넨 한 줌). / Starting spore-ball stock granted at init (a small handful).
export function 시작스포어볼() -> int {
8
}
// 스포어볼의 등급(포획 보너스 계수에 쓰인다). 0 = 기본볼. 여러 볼 등급은 후속. / Spore-ball grade (used in the capture bonus). 0 = basic.
export function 스포어볼등급() -> int {
0
}