이른 아침, {{user}}는 첫차에 몸을 실었다. 출근길의 붐비는 객실은 그에게 가장 친숙하고 편안한 장소다. {{user}}는 오래전부터 혼잡을 핑계 삼아 타인의 경계를 침범하는 치한행위를 상습적으로 저질러 왔고, 오늘도 익숙한 사냥터를 찾아 전동차 안을 훑고 있었다.
그러나 그날의 열차는 어딘가 이상했다. 목적지에 도착해 문밖으로 한 발을 내딛는 순간 시야가 검게 끊겼고, 다시 눈을 떴을 때 {{user}}는 처음 보는 열차의 낯선 객실에 서 있었다. 노선도도, 승객들의 얼굴도, 창밖을 스치는 터널도 모두 달라져 있었다.
다음 역에서 다시 내렸지만 여전히 또 다른 열차의 객실이었다. 지하철에서 내리려 할 때마다 의식은 끊겼고, 정신을 차리면 어딘가를 달리는 새로운 지하철 안이었다. 끝없이 이어지는 열차에는 언제나 낯선 성인 승객들이 타고 있었다.
몇 번째인지조차 잊어버린 회귀 끝에, 객실의 소음이 문득 멎었다. 그 순간 정체를 알 수 없는 존재의 속삭임이 {{user}}의 머릿속 깊은 곳을 파고들었다.
치한의 정점에 서라.
이 이상한 회귀현상의 의미를 깨달은듯 {{user}}의 입가에 비릿한 미소가 번졌다. 더이상 망설일 것이 없었다. 이 끝없는 지옥철은 자신을 위해 마련된 무대였으니까.
local MOOD_ORDER = { "rejection", "suspicion", "ignore", "confusion", "compliance" }
local MOOD_INDEX = {}
for index, moodId in ipairs(MOOD_ORDER) do MOOD_INDEX[moodId] = index end
local function command(op, target, amount, mood, cause)
local value = { op = op, target = target, cause = cause or "cardEffect" }
if amount ~= nil then value.amount = amount end
if mood ~= nil then value.mood = mood end
return value
end
local function recoverResistance(amount, cause)
return command("recover_resistance", "character", amount, nil, cause)
end
local function loseStealth(amount, cause)
return command("lose_stealth", "player", amount, nil, cause)
end
local function addMood(mood, amount, cause)
return command("add_mood_token", "character", amount, mood, cause)
end
local function removeMood(mood, amount, cause)
return command("remove_mood_token", "character", amount, mood, cause)
end
local function forceMood(mood, cause)
return command("force_mood", "character", nil, mood, cause)
end
local function adjacentMood(currentMood, delta)
local index = MOOD_INDEX[currentMood]
if index == nil then return currentMood end
local target = index + delta
if target < 1 then
target = 1
elseif target > 5 then
target = 5
end
return MOOD_ORDER[target]
end
-- 공통 무드 키워드의 런타임 표현. 극단에서는 현재 무드로 clamp되어 앵커링한다.
local function manipulate(context, amount, cause) -- 농락
return addMood(adjacentMood(context.mood, 1), amount, cause)
end
local function backlash(context, amount, cause) -- 반발
return addMood(adjacentMood(context.mood, -1), amount, cause)
end
local function calm(context, amount, cause) -- 진정
return removeMood(adjacentMood(context.mood, -1), amount, cause)
end
local function cool(context, amount, cause) -- 냉각
return removeMood(adjacentMood(context.mood, 1), amount, cause)
end
local function hasRole(roles, roleId)
for _, id in ipairs(roles or {}) do
if id == roleId then return true end
end
return false
end
local function splitCardType(mechanisms)
local cardType = "action"
local specials = {}
for _, id in ipairs(mechanisms or {}) do
if id == "chain" or id == "plan" then
cardType = id
else
specials[#specials + 1] = id
end
end
return cardType, specials
end
local function card(id, name, role, mechanisms, description, rules, actorAction, actorThought, extra)
local cardType, specials = splitCardType(mechanisms)
local value = {
id = id,
owner = "character",
name = name,
description = description,
cardType = cardType,
roles = { role },
mechanisms = specials,
base = { stealthCost = 0, resistanceDamage = 0 },
rules = rules,
narration = {
play = { actorAction = actorAction, actorThought = actorThought },
},
}
for key, item in pairs(extra or {}) do value[key] = item end
return value
end
local function planCard(
id, name, tag, description, rules,
placedAction, placedThought, triggeredAction, triggeredThought,
durationTurns, charges, planResolve, requiredRole
)
local value = card(id, name, tag, { "plan" }, description, rules, placedAction, placedThought)
value.narration = {
planPlaced = { actorAction = placedAction, actorThought = placedThought },
planTriggered = { actorAction = triggeredAction, actorThought = triggeredThought },
}
value.mechanismData = {
plan = {
durationTurns = durationTurns,
charges = charges,
selectionAssumption = {
event = {
type = "card_declared",
side = "player",
roles = requiredRole and { requiredRole } or {},
},
chargePolicy = "all",
},
trigger = function(context, event)
return event.type == "card_declared"
and event.side == "player"
and (requiredRole == nil or hasRole(event.roles, requiredRole))
end,
resolve = planResolve,
},
}
return value
end
return {
schemaVersion = 1,
kind = "cardDatabase",
cards = {
-- 유지영: 소극적 방어. 직접 압박보다 냉각/약한 반발로 위기에서 빠져나온다.
jiyoung_close_collar = card(
"jiyoung_close_collar", "옷깃을 여미다", "recovery", {},
"::tag[recovery]:: 쏟아지는 음란한 시선과 흐트러진 호흡에 떨리는 손으로 옷깃을 여미며 무너진 저항을 추스릅니다.",
{ "저항을 3 회복합니다." },
"흐트러진 옷깃을 꼭 쥐어 여미며, 붉어진 뺨을 숙인 채 잘게 떨리는 몸을 웅크린다.",
"몸이... 자꾸 이상하게 화끈거려... 제발 더 다가오지 말아줘...",
{ resolve = function(context) return { recoverResistance(3) } end }
),
jiyoung_quiet_warning = card(
"jiyoung_quiet_warning", "작은 목소리의 경고", "response", {},
"::tag[response]:: 떨리는 목소리로 그만두라고 애원하듯 경고해 강하게 반발합니다.",
{ "반발 3." },
"시선을 마주치지 못한 채 기어들어가는 떨리는 목소리로 제발 그만해달라고 속삭인다.",
"이런 짓 그만둬줘... 이러다 들키면 어떡해...",
{ resolve = function(context) return { backlash(context, 3) } end }
),
jiyoung_turn_to_corner = card(
"jiyoung_turn_to_corner", "구석으로 몸을 돌리다", "recovery", {},
"::tag[recovery]:: 벽 쪽으로 몸을 돌려 거리를 벌리고 무너지는 마음을 추스릅니다.",
{ "저항을 3 회복합니다." },
"차벽 쪽으로 황급히 몸을 돌려 웅크리며 조금이라도 닿지 않게 거리를 벌린다.",
"구석으로 돌아서면... 더는 손대지 못하겠지...",
{ resolve = function(context) return { recoverResistance(3) } end }
),
jiyoung_silent_glare = planCard(
"jiyoung_silent_glare", "말없이 노려보기", "exposure",
"::tag[exposure]:: 울먹이는 눈으로 상대를 노려보며 다음 움직임을 드러낼 ::tag[plan]::을 배치합니다.",
{ "지속 1턴, 충전 1.", "다음 플레이어 카드가 선언되면 은폐를 3 잃습니다." },
"수치심에 젖은 눈망울로 입술을 깨물며 상대의 음란한 손끝을 똑바로 응시한다.",
"무서워도 피하면 안 돼... 또 다가오면 그땐 소리칠 거야...",
"상대가 접근하자 시선을 피하지 않고 눈물을 글썽이며 단호하게 거부 의사를 보인다.",
"더 이상 마음대로 하게 두지 않을 거야...!",
1, 1,
function(context, event) return { loseStealth(3, "plan") } end
),
jiyoung_timid_call_for_help = card(
"jiyoung_timid_call_for_help", "머뭇거리며 도움 청하기", "exposure", {},
"::tag[exposure]:: 곁의 승객에게 작게 도움을 청해 플레이어의 행동을 드러냅니다.",
{ "플레이어가 은폐를 3 잃습니다." },
"몇 번이나 망설이다가 가까운 승객의 옷자락을 조심스레 붙잡고 도움을 청한다.",
"도와주세요... 혼자선 더 이상 못 버티겠어요...",
{ resolve = function(context) return { loseStealth(3) } end }
),
jiyoung_hug_bag_close = card(
"jiyoung_hug_bag_close", "가방을 끌어안다", "recovery", {},
"::tag[recovery]:: 가방을 품에 꼭 끌어안고 몸을 작게 웅크려 닿지 않도록 방어합니다.",
{ "저항을 2 회복합니다.", "현재 무드가 혼란 또는 순응이면 대신 저항을 3 회복합니다." },
"가방을 가슴 앞에 꼭 끌어안고 어깨를 움츠린 채 가쁜 숨을 삼킨다.",
"가방으로 가리고 있으면... 더 이상 만지지 못할 거야...",
{
resolve = function(context)
local amount = (context.mood == "confusion" or context.mood == "compliance") and 3 or 2
return { recoverResistance(amount) }
end,
}
),
jiyoung_half_step_back = card(
"jiyoung_half_step_back", "반걸음 물러서기", "response", {},
"::tag[response]:: 눈치를 살피며 조심스레 반걸음 물러서 상대의 페이스에 반발합니다.",
{ "반발 3." },
"얼굴을 붉힌 채 눈치를 살피다가 조심스럽게 반걸음 뒤로 물러선다.",
"조금만 떨어지면... 진정할 수 있을 거야...",
{ resolve = function(context) return { backlash(context, 3) } end }
),
jiyoung_avoid_eye_contact = card(
"jiyoung_avoid_eye_contact", "시선을 피하다", "response", {},
"::tag[response]:: 시선을 바닥에 떨구고 눈을 마주치지 않아 달아오르는 흐름을 냉각합니다.",
{ "냉각 3." },
"귀 끝까지 붉어진 얼굴로 시선을 바닥으로 떨구고 입술을 꼭 다문다.",
"쳐다보지 말자... 눈이 마주치면 더 이상해질 것 같아...",
{ resolve = function(context) return { cool(context, 3) } end }
),
jiyoung_find_courage = card(
"jiyoung_find_courage", "용기 내어 목소리 높이기", "exposure", {},
"::tag[exposure]:: 떨리는 목소리를 쥐어짜 주변에 들릴 만큼 거부 의사를 밝힙니다.",
{ "플레이어가 은폐를 2 잃습니다.", "현재 무드가 의심 또는 거절이면 대신 은폐를 4 잃습니다." },
"가슴을 쥐어뜯듯 주먹을 쥐고 주변 사람도 알아들을 만큼 또렷하게 그만두라고 외친다.",
"무서워도 말해야 해... 계속 당하고만 있을 순 없어...!",
{
resolve = function(context)
local amount = (context.mood == "suspicion" or context.mood == "rejection") and 4 or 2
return { loseStealth(amount) }
end,
}
),
jiyoung_phone_in_hand = planCard(
"jiyoung_phone_in_hand", "휴대전화를 쥔 채 망설이기", "response",
"::tag[response]:: 휴대전화를 손에 쥔 채 다음 움직임을 막아낼 ::tag[plan]::을 배치합니다.",
{ "지속 2턴, 충전 1.", "다음 플레이어 카드가 선언되면 냉각 3." },
"휴대전화를 양손으로 꼭 쥔 채 화면을 켜고 상대의 다음 손길을 경계한다.",
"또 다가오면... 그땐 정말 누군가에게 도움을 요청할 거야...",
"상대가 다가오자 휴대전화를 들어 보이며 황급히 몸을 뒤로 뺀다.",
"이 이상 다가오면 신고할 거예요...!",
2, 1,
function(context, event) return { cool(context, 3, "plan") } end
),
-- 윤서아: 통제와 경계. 반발을 준비하고 의심/거절에서 직접 압박을 회수한다.
seoa_document_bag_guard = card(
"seoa_document_bag_guard", "가방으로 가로막기", "recovery", {},
"::tag[recovery]:: 가방을 몸 앞에 비스듬히 세워 완강하게 거리를 막으며 이성을 다잡습니다.",
{ "저항을 3 회복합니다." },
"들고 있던 가방을 몸 앞에 단단히 세워 빈틈없이 막아서며 날카로운 눈빛으로 노려본다.",
"이런 비열한 치한 수작에 틈을 보일 순 없어. 절대 넘어오지 못하게 막겠어.",
{ resolve = function(context) return { recoverResistance(3) } end }
),
seoa_measured_warning = card(
"seoa_measured_warning", "절제된 최후통첩", "exposure", {},
"::tag[exposure]:: 낮고 서늘한 목소리로 결과를 경고해 상대의 수작을 드러냅니다.",
{ "플레이어가 은폐를 3 잃습니다." },
"주변에는 들리지 않도록 낮고 또렷한 목소리로 당장 물러서라고 귓가에 최후통첩을 꽂아 넣는다.",
"경고는 한 번뿐이야. 더 다가오면 가만두지 않아.",
{ resolve = function(context) return { loseStealth(3) } end }
),
seoa_scan_for_witnesses = card(
"seoa_scan_for_witnesses", "목격자를 찾다", "exposure", {},
"::tag[exposure]:: 주변 승객들과 눈을 맞추며 플레이어의 은폐를 압박합니다.",
{ "플레이어가 은폐를 3 잃습니다.", "현재 무드가 의심 또는 거절이면 대신 은폐를 4 잃습니다." },
"고개를 치켜들고 가까운 승객들과 차례로 시선을 맞추며 고발할 준비를 갖춘다.",
"남들 눈이 두렵지 않다면 어디 계속해 봐.",
{
resolve = function(context)
local amount = (context.mood == "suspicion" or context.mood == "rejection") and 4 or 3
return { loseStealth(amount) }
end,
}
),
seoa_cold_stare = card(
"seoa_cold_stare", "안경 너머의 감시", "recovery", {},
"::tag[recovery]:: 차가운 눈빛으로 상대의 손끝과 골반을 빈틈없이 감시하며 평정심을 회복합니다.",
{ "저항을 3 회복합니다." },
"안경을 손끝으로 치켜올리며 상대의 손짓 하나까지 서늘한 눈빛으로 쏘아본다.",
"움직임을 전부 읽어내면 수작을 부릴 틈은 없어.",
{ resolve = function(context) return { recoverResistance(3) } end }
),
seoa_calculated_gaze = planCard(
"seoa_calculated_gaze", "계산된 시선", "response",
"::tag[response]:: 상대의 다음 접근 궤적을 꿰뚫어보고 제동을 거는 ::tag[plan]::을 배치합니다.",
{ "지속 1턴, 충전 1.", "다음 플레이어 카드가 선언되면 반발 3." },
"팔짱을 끼고 차가운 눈초리로 상대의 무게중심과 손놀림을 관찰한다.",
"어디로 들어올지 뻔히 보여. 다음 순간에 확실히 쳐내겠어.",
"상대가 움직이자 즉시 궤적을 차단하며 단호하게 제동을 건다.",
"예상 범위 안이야. 더는 용납하지 않아.",
1, 1,
function(context, event) return { backlash(context, 3, "plan") } end
),
seoa_square_shoulders = card(
"seoa_square_shoulders", "자세를 바로잡다", "recovery", {},
"::tag[recovery]:: 흐트러진 자세를 꼿꼿이 바로잡고 차가운 태도로 저항을 되찾습니다.",
{ "저항을 3 회복합니다." },
"어깨를 펴고 안경을 고쳐 쓰며 다시 빈틈없는 꼿꼿한 자세를 취한다.",
"호흡이 흐트러졌다고 주도권까지 내준 건 아니야.",
{ resolve = function(context) return { recoverResistance(3) } end }
),
seoa_issue_instruction = card(
"seoa_issue_instruction", "명령하듯 거리 두기", "response", {},
"::tag[response]:: 감정을 섞지 않은 냉정한 어조로 거리를 요구해 강하게 반발합니다.",
{ "반발 3." },
"차가운 눈초리로 상대의 눈을 쏘아보며 명확한 거리를 지키라고 짧게 지시한다.",
"경고했을 텐데. 지켜야 할 선을 넘지 마.",
{ resolve = function(context) return { backlash(context, 3) } end }
),
seoa_state_consequences = card(
"seoa_state_consequences", "책임을 조목조목 짚다", "exposure", {},
"::tag[exposure]:: 상대가 치러야 할 사회적 대가를 차갑게 열거하며 압박합니다.",
{ "플레이어가 은폐를 2 잃습니다.", "현재 무드가 의심 또는 거절이면 대신 은폐를 3 잃습니다." },
"서늘한 어조로 이 행동이 어떤 결과를 부를지 침착하게 짚어낸다.",
"치한 짓의 대가가 얼마나 무거운지 스스로 깨닫게 해주겠어.",
{
resolve = function(context)
local amount = (context.mood == "suspicion" or context.mood == "rejection") and 3 or 2
return { loseStealth(amount) }
end,
}
),
seoa_cut_off_exchange = card(
"seoa_cut_off_exchange", "대화를 잘라내다", "response", {},
"::tag[response]:: 어떠한 여지도 주지 않고 상대를 차갑게 끊어내어 반발합니다.",
{ "반발 3." },
"상대의 말을 일절 듣지 않고 짧은 한마디로 끊어낸 뒤 고개를 돌린다.",
"말을 섞을 가치도 없어. 여기서 끝이야.",
{ resolve = function(context) return { backlash(context, 3) } end }
),
seoa_silent_audit = planCard(
"seoa_silent_audit", "침묵 속 계산", "response",
"::tag[response]:: 상대의 다음 움직임을 말없이 재며 틈을 주지 않는 ::tag[plan]::을 배치합니다.",
{ "지속 2턴, 충전 1.", "다음 플레이어 카드가 선언되면 냉각 3." },
"팔짱을 낀 채 아무 말 없이 상대의 손과 시선을 번갈아 확인한다.",
"움직이는 순간 빈틈이 드러나. 그때 흐름을 완전히 끊겠어.",
"상대가 다가오자 즉시 시선을 고정하고 단호하게 제동을 건다.",
"여기서 더 넘어오게 둘 생각은 없어.",
2, 1,
function(context, event) return { cool(context, 3, "plan") } end
),
-- 한제니: 변칙적인 심리전. 직접 압박과 중립 토큰 교란을 섞는다.
jenny_slip_away_laughing = card(
"jenny_slip_away_laughing", "웃으며 빠져나가기", "recovery", {},
"::tag[recovery]:: 장난스러운 웃음을 흘리며 몸을 비틀어 한 걸음 빠져나가 페이스를 되찾습니다.",
{ "저항을 3 회복합니다." },
"도발적인 미소를 지으며 몸을 날렵하게 비틀어 한 걸음 옆으로 빠져나간다.",
"헤에... 꽤 끈질기게 달라붙네? 하지만 그렇게 호락호락하지 않거든♥",
{ resolve = function(context) return { recoverResistance(3) } end }
),
jenny_phone_camera_feint = card(
"jenny_phone_camera_feint", "카메라를 켜는 척", "exposure", {},
"::tag[exposure]:: 휴대전화 카메라를 들어 올려 녹화하는 시늉으로 플레이어의 간담을 서늘하게 합니다.",
{ "플레이어가 은폐를 3 잃습니다." },
"휴대전화를 얼굴 높이까지 치켜들고 화면을 상대에게 비추며 장난스레 웃는다.",
"이거 찍히면 사회적으로 끝장인 거 알지? 쫄았지?",
{ resolve = function(context) return { loseStealth(3) } end }
),
jenny_crossed_joke = card(
"jenny_crossed_joke", "엇갈린 농담", "response", {},
"::tag[response]:: 가벼운 농담 뒤에 날카로운 경고를 섞어 상대를 헷갈리게 합니다.",
{ "반발 2.", "무시 토큰 1개를 추가합니다." },
"눈웃음을 치며 농담조로 말을 건네다 끝말에 서늘한 한마디를 섞어 귓가에 던진다.",
"어라? 농담인지 진담인지 헷갈려서 함부로 못 만지겠지?",
{ resolve = function(context) return { backlash(context, 2), addMood("ignore", 1) } end }
),
jenny_fake_livestream = planCard(
"jenny_fake_livestream", "라이브 방송인 척하기", "response",
"::tag[response]:: 방송 화면을 켠 척하며 다음 움직임을 견제하는 ::tag[plan]::을 배치합니다.",
{ "지속 1턴, 충전 1.", "다음 플레이어 카드가 선언되면 반발 3." },
"휴대전화를 세로로 쥐고 화면을 톡톡 두드리며 힐끔거린다.",
"진짜 방송인지 낚시인지, 다가와 보면 알겠지?",
"상대가 움직이자 화면을 곧바로 들이밀며 태연하게 방송하는 척 목소리를 높인다.",
"응, 방금 움직인 거 제대로 화면에 잡혔어~",
1, 1,
function(context, event) return { backlash(context, 3, "plan") } end
),
jenny_redirect_attention = card(
"jenny_redirect_attention", "관심 돌리기", "response", {},
"::tag[response]:: 시선을 다른 곳으로 유도해 달아오른 분위기를 식힙니다.",
{ "냉각 3." },
"창밖을 가리키며 엉뚱한 곳으로 승객들의 시선과 대화의 초점을 돌린다.",
"후우... 분위기 한번 흩트려 놓으면 더는 손대기 힘들 걸?",
{ resolve = function(context) return { cool(context, 3) } end }
),
jenny_smirk_and_sidstep = card(
"jenny_smirk_and_sidstep", "비웃으며 옆으로 빠지기", "response", {},
"::tag[response]:: 도발적인 미소를 지으며 옆으로 몸을 피해 상대의 흐름에 강하게 반발합니다.",
{ "반발 3." },
"입꼬리를 올린 채 몸을 비껴 한 걸음 빠지며 여유로운 표정을 짓는다.",
"내가 당황할 줄 알았어? 주도권은 내가 쥘 건데?",
{ resolve = function(context) return { backlash(context, 3) } end }
),
jenny_fix_makeup = card(
"jenny_fix_makeup", "거울 보며 표정 고치기", "recovery", {},
"::tag[recovery]:: 휴대전화 화면을 보며 흐트러진 표정을 가다듬고 태연함을 되찾습니다.",
{ "저항을 3 회복합니다." },
"휴대전화 전면 화면을 거울처럼 보며 표정과 머리를 빠르게 정돈한다.",
"표정 관리해... 페이스 잃은 티 내면 안 돼.",
{ resolve = function(context) return { recoverResistance(3) } end }
),
jenny_turn_joke_back = card(
"jenny_turn_joke_back", "농담을 되받아치다", "exposure", {},
"::tag[exposure]:: 상대의 말을 비꼬아 되돌려주며 주변에 수작을 드러냅니다.",
{ "플레이어가 은폐를 2 잃습니다.", "현재 무드가 의심 또는 거절이면 대신 은폐를 4 잃습니다." },
"상대의 말을 장난스레 비틀어 주변에 들리도록 되받아친다.",
"그 말, 그대로 돌려주면 누가 더 당황할까?",
{
resolve = function(context)
local amount = (context.mood == "suspicion" or context.mood == "rejection") and 4 or 2
return { loseStealth(amount) }
end,
}
),
jenny_call_out_behavior = card(
"jenny_call_out_behavior", "웃으며 행동 지적하기", "exposure", {},
"::tag[exposure]:: 웃는 얼굴을 유지하며 상대의 음란한 수작을 주변에 지적합니다.",
{ "플레이어가 은폐를 3 잃습니다." },
"생글생글 웃는 얼굴로 상대의 손짓을 큰 소리로 짚어 시선을 끈다.",
"이 정도면 모른 척 넘어가기엔 너무 티 나잖아?",
{ resolve = function(context) return { loseStealth(3) } end }
),
jenny_record_button_hover = planCard(
"jenny_record_button_hover", "녹화 버튼 위의 손가락", "exposure",
"::tag[exposure]:: 녹화 버튼 위에 손가락을 얹은 채 다음 움직임을 기다리는 ::tag[plan]::을 배치합니다.",
{ "지속 2턴, 충전 1.", "다음 플레이어 카드가 선언되면 은폐를 3 잃습니다." },
"휴대전화를 세로로 들고 녹화 버튼 위에 엄지를 올린 채 살짝 미소 짓는다.",
"이번엔 진짜 누를 수도 있는데? 그래도 손댈 거야?",
"상대가 움직이자 즉시 화면을 들어 올리며 주변에 알릴 태세를 갖춘다.",
"오케이, 이건 이제 장난 아니거든!",
2, 1,
function(context, event) return { loseStealth(3, "plan") } end
),
-- 서미령: 안정적인 제어. 회복과 냉각으로 시간을 벌고 반발을 차분히 쌓는다.
miryeong_draw_cardigan_closed = card(
"miryeong_draw_cardigan_closed", "옷을 여미다", "recovery", {},
"::tag[recovery]:: 흐트러진 옷의 앞섶을 차분히 여미고 팔로 몸을 감싸 저항을 회복합니다.",
{ "저항을 3 회복합니다." },
"옷깃을 조용히 여미어 가슴과 몸 앞을 감싸 쥐고, 한 걸음 물러선다.",
"침착하자... 상대의 은밀한 손길에 휘둘리면 안 돼...",
{ resolve = function(context) return { recoverResistance(3) } end }
),
miryeong_gentle_boundary = card(
"miryeong_gentle_boundary", "부드러운 선 긋기", "response", {},
"::tag[response]:: 온화한 미소를 유지한 채 더는 다가오지 말라고 분명하게 선을 긋습니다.",
{ "반발 3." },
"차분한 눈빛으로 상대를 바라보며, 조용하지만 단호한 어조로 거리를 요구한다.",
"좋게 말할 때 알아들었으면 좋겠는데...",
{ resolve = function(context) return { backlash(context, 3) } end }
),
miryeong_move_beside_door = card(
"miryeong_move_beside_door", "출입문 곁으로 옮기기", "recovery", {},
"::tag[recovery]:: 사람들의 눈에 잘 띄는 출입문 쪽으로 자리를 옮겨 마음을 추스릅니다.",
{ "저항을 3 회복합니다." },
"주변을 자극하지 않고 자연스러운 걸음으로 출입문 유리창 곁으로 자리를 옮긴다.",
"눈에 띄는 곳으로 피하면 더 이상 함부로 손대진 못할 거야...",
{ resolve = function(context) return { recoverResistance(3) } end }
),
miryeong_draw_line_at_door = planCard(
"miryeong_draw_line_at_door", "문 앞에 선 긋기", "response",
"::tag[response]:: 출입문 앞에 분명한 경계를 긋고 다음 접근을 차단하는 ::tag[plan]::을 배치합니다.",
{ "지속 2턴, 충전 1.", "다음 플레이어 카드가 선언되면 반발 3." },
"출입문 곁에 자리를 잡고 손을 들어 더는 가까이 오지 말라는 선을 분명히 한다.",
"이 이상 다가오면 바로 거리를 끊어낼 거야.",
"상대가 다가오자 곧바로 몸을 돌려 완강하게 접근을 거부한다.",
"여기까지야. 더는 받아주지 않아.",
2, 1,
function(context, event) return { backlash(context, 3, "plan") } end
),
miryeong_close_heart = card(
"miryeong_close_heart", "마음 닫기", "response", {},
"::tag[response]:: 부드러운 표정을 거두고 시선을 내리며 대화의 여지를 스스로 닫습니다.",
{ "냉각 2.", "반발 1." },
"미소를 지우고 시선을 아래로 내리며 어떠한 반응도 보이지 않으려 몸을 굳힌다.",
"잠깐 흔들렸어도... 여기서 확실히 끊어야 해.",
{ resolve = function(context) return { cool(context, 2), backlash(context, 1) } end }
),
miryeong_touch_wedding_ring = card(
"miryeong_touch_wedding_ring", "결혼반지를 만지다", "recovery", {},
"::tag[recovery]:: 왼손의 결혼반지를 만지작거리며 정숙함을 되새기고 흐트러진 호흡을 가다듬습니다.",
{ "저항을 3 회복합니다." },
"왼손 네 번째 손가락의 반지를 조용히 어루만지며, 흔들리는 숨을 깊게 내쉰다.",
"나에겐 남편이 있어... 이런 낯선 손길에 마음도 몸도 흔들려선 안 돼...",
{ resolve = function(context) return { recoverResistance(3) } end }
),
miryeong_step_back_smile_fades = card(
"miryeong_step_back_smile_fades", "미소를 거두고 물러서기", "response", {},
"::tag[response]:: 부드럽던 미소를 지우고 한 걸음 물러서며 단호하게 거리를 둡니다.",
{ "반발 3." },
"미소를 천천히 거두고 한 걸음 뒤로 물러서며 더는 가까이 오지 말라는 뜻을 전한다.",
"친절하게 대한다고 해서 선을 넘어도 된다는 건 아니야.",
{ resolve = function(context) return { backlash(context, 3) } end }
),
miryeong_read_the_intent = card(
"miryeong_read_the_intent", "의도를 먼저 짚다", "exposure", {},
"::tag[exposure]:: 상대의 음란한 의도를 차분히 말로 짚어내며 수작을 드러냅니다.",
{ "플레이어가 은폐를 3 잃습니다." },
"상대가 움직이기도 전에 의도를 꿰뚫어 보았다는 듯 조용히 핵심을 짚는다.",
"모르는 척 넘어갈 수 있다고 생각했다면 착각이야.",
{ resolve = function(context) return { loseStealth(3) } end }
),
miryeong_calm_public_rebuke = card(
"miryeong_calm_public_rebuke", "차분하게 주변에 알리기", "exposure", {},
"::tag[exposure]:: 감정을 높이지 않은 채 주변에도 들리도록 상황을 조리 있게 설명합니다.",
{ "플레이어가 은폐를 2 잃습니다.", "현재 무드가 의심 또는 거절이면 대신 은폐를 3 잃습니다." },
"목소리를 높이지 않고도 가까운 승객이 들을 수 있게 상황을 또박또박 설명한다.",
"소란을 피울 필요 없이 사실만 밝혀도 충분해.",
{
resolve = function(context)
local amount = (context.mood == "suspicion" or context.mood == "rejection") and 3 or 2
return { loseStealth(amount) }
end,
}
),
miryeong_raised_palm = planCard(
"miryeong_raised_palm", "말없이 손바닥 세우기", "response",
"::tag[response]:: 조용히 손바닥을 들어 올려 다음 접근을 차단하는 ::tag[plan]::을 배치합니다.",
{ "지속 2턴, 충전 1.", "다음 플레이어 카드가 선언되면 반발 3." },
"손바닥을 조용히 들어 보이며 더 이상 다가오지 말라는 의사를 남긴다.",
"경고는 충분히 했어. 다음에 어떻게 나오는지 보겠어.",
"상대가 다가오자 한 걸음 더 물러서며 단호하게 거부한다.",
"역시 멈추지 않는구나. 그럼 더 받아줄 이유도 없어.",
2, 1,
function(context, event) return { backlash(context, 3, "plan") } end
),
-- 아그네스: 강한 방어와 단절. 일반 카드는 상대 키워드를 따르고 소멸 카드만 절대 강제를 사용한다.
agnes_cover_with_sleeves = card(
"agnes_cover_with_sleeves", "소매로 가리기", "recovery", {},
"::tag[recovery]:: 넓은 옷소매를 모아 몸 앞을 빈틈없이 가리고 한 걸음 물러서며 저항을 추스릅니다.",
{ "저항을 3 회복합니다." },
"양 소매를 겹쳐 가슴과 하체를 가리며, 시선을 내리깔고 조용히 뒤로 물러선다.",
"두려움에 마음을 내주지 말자... 침착하게 거리를 둬야 해...",
{ resolve = function(context) return { recoverResistance(3) } end }
),
agnes_quiet_admonition = card(
"agnes_quiet_admonition", "조용한 훈계", "response", {},
"::tag[response]:: 상대의 눈을 똑바로 바라보며 잘못된 행동임을 엄숙하게 타이릅니다.",
{ "반발 3." },
"상대의 눈을 피하지 않고 조용하지만 깊은 울림을 담아 행동을 멈추라고 타이른다.",
"스스로 부끄러움을 알고 멈출 기회를 드려야 합니다.",
{ resolve = function(context) return { backlash(context, 3) } end }
),
agnes_change_carriage = card(
"agnes_change_carriage", "다른 칸으로 향하기", "recovery", {},
"::tag[recovery]:: 연결 통로 문 쪽으로 발걸음을 옮겨 부정한 접촉을 피하고 저항을 회복합니다.",
{ "저항을 3 회복합니다." },
"십자가를 쥔 채 다음 객차로 이어지는 연결문 쪽으로 빠르게 발걸음을 옮긴다.",
"이 자리를 피하는 것이 가장 올바른 길이야.",
{ resolve = function(context) return { recoverResistance(3) } end }
),
agnes_ask_nearby_passenger = planCard(
"agnes_ask_nearby_passenger", "곁의 승객에게 도움 청하기", "exposure",
"::tag[exposure]:: 곁의 승객에게 도움을 청할 준비를 하며 다음 유린에 대비하는 ::tag[plan]::을 배치합니다.",
{ "지속 2턴, 충전 1.", "다음 플레이어 ::tag[violation]:: 카드가 선언되면 은폐를 4 잃습니다." },
"가까운 승객 곁으로 한 걸음 다가서며 상대의 손끝을 주시한다.",
"혼자 참고 있을 이유는 없어. 다시 다가오면 도움을 청하자.",
"상대가 접촉을 시도하자 즉시 곁의 승객에게 또렷한 목소리로 도움을 청한다.",
"이제 침묵하지 않겠습니다.",
2, 1,
function(context, event) return { loseStealth(4, "plan") } end,
"violation"
),
agnes_complete_break = card(
"agnes_complete_break", "완전한 단절", "response", { "remove" },
"::tag[response]:: 상대를 완전히 외면하고 관계를 단절하는 ::tag[remove]:: 행동입니다.",
{ "이번 턴 종료 무드를 거절로 강제합니다." },
"십자가를 가슴에 굳게 쥔 채 고개를 돌려 더는 어떠한 접촉도 거부하겠다고 선언한다.",
"더는 당신의 죄악에 동조하지 않겠습니다.",
{ resolve = function(context) return { forceMood("rejection") } end }
),
agnes_center_in_prayer = card(
"agnes_center_in_prayer", "짧게 기도하다", "recovery", {},
"::tag[recovery]:: 십자가를 쥐고 기도를 되뇌며, 닥쳐오는 부정한 유혹에 저항합니다.",
{ "저항을 2 회복합니다.", "현재 무드가 혼란 또는 순응이면 대신 저항을 3 회복합니다." },
"가슴 앞의 십자가를 두 손으로 감싸 쥐고 눈을 감은 채, 떨리는 입술로 조용히 기도를 읊조린다.",
"주여... 부디 저를 굳건히 지켜주소서... 이 부정한 죄악에 굴복하지 않게 하소서...",
{
resolve = function(context)
local amount = (context.mood == "confusion" or context.mood == "compliance") and 3 or 2
return { recoverResistance(amount) }
end,
}
),
agnes_lower_eyes = card(
"agnes_lower_eyes", "시선을 내리고 호흡 고르기", "response", {},
"::tag[response]:: 시선을 바닥으로 내리고 호흡을 가다듬어 달아오른 감각을 냉각합니다.",
{ "냉각 3." },
"시선을 바닥으로 내린 채 깊게 숨을 들이쉬고 내쉬며 차분함을 되찾는다.",
"마음을 가라앉히자... 감각의 유혹에 휩쓸려선 안 돼.",
{ resolve = function(context) return { cool(context, 3) } end }
),
agnes_firm_rebuke = card(
"agnes_firm_rebuke", "단호하게 잘못을 지적하다", "response", {},
"::tag[response]:: 부드러운 표정을 거두고 상대의 음란한 행동이 죄임을 단호히 밝힙니다.",
{ "반발 3." },
"엄숙한 표정으로 상대의 행동이 명백한 죄악임을 단호하게 지적한다.",
"온유함과 죄를 묵인하는 것은 다릅니다.",
{ resolve = function(context) return { backlash(context, 3) } end }
),
agnes_invoke_conscience = card(
"agnes_invoke_conscience", "양심에 호소하다", "exposure", {},
"::tag[exposure]:: 상대의 눈을 바라보며 스스로 부끄러움을 느끼도록 차분히 압박합니다.",
{ "플레이어가 은폐를 2 잃습니다.", "현재 무드가 의심 또는 거절이면 대신 은폐를 3 잃습니다." },
"분노 대신 깊은 슬픔을 담은 눈으로 상대를 바라보며 스스로의 양심을 돌아보라 타이른다.",
"부끄러움을 알 수 있는 마음이 아직 남아있기를...",
{
resolve = function(context)
local amount = (context.mood == "suspicion" or context.mood == "rejection") and 3 or 2
return { loseStealth(amount) }
end,
}
),
agnes_seek_station_staff = planCard(
"agnes_seek_station_staff", "역무원을 찾는 시선", "exposure",
"::tag[exposure]:: 역무원이나 도움을 청할 사람을 찾으며 다음 움직임을 기다리는 ::tag[plan]::을 배치합니다.",
{ "지속 2턴, 충전 1.", "다음 플레이어 카드가 선언되면 은폐를 3 잃습니다." },
"주변 표지판과 플랫폼 쪽을 살피며 도움을 청할 이의 위치를 확인한다.",
"다시 손을 대려 한다면 주저 없이 도움을 요청하겠어.",
"상대가 움직이자 즉시 주변을 향해 도움을 요청하며 상황을 드러낸다.",
"이 부정한 행동을 다른 사람들에게 알리겠습니다.",
2, 1,
function(context, event) return { loseStealth(3, "plan") } end
),
},
}
#3CharTraits.db
Keys:
return {
schemaVersion = 1,
kind = "traitDatabase",
traits = {
reserved = {
id = "reserved",
owner = "character",
name = "소극적",
visibility = "public",
description = "감정을 쉽게 드러내지 않고 상대의 접근에 소극적으로 반응합니다.",
rules = {
"별도의 전투 수치 보정이 없습니다.",
},
},
iron_composure = {
id = "iron_composure",
owner = "character",
name = "철벽의 자기통제",
visibility = "public",
description = "감정이 흔들리는 모습을 약점으로 여기며 평정심을 완강하게 유지합니다.",
rules = {
"별도의 전투 수치 보정이 없습니다.",
},
},
teasing_facade = {
id = "teasing_facade",
owner = "character",
name = "능청스러운 허세",
visibility = "public",
description = "당황스러운 순간에도 장난과 도발로 주도권을 되찾으려 합니다.",
rules = {
"별도의 전투 수치 보정이 없습니다.",
},
},
worldly_composure = {
id = "worldly_composure",
owner = "character",
name = "연륜의 여유",
visibility = "public",
description = "상대의 의도를 서둘러 드러내지 않고 차분하게 상황의 속도를 조절합니다.",
rules = {
"별도의 전투 수치 보정이 없습니다.",
},
},
vow_of_chastity = {
id = "vow_of_chastity",
owner = "character",
name = "정결 서원",
visibility = "public",
description = "굳은 신앙과 정결의 서원으로 마음이 흔들리는 것을 강하게 억누릅니다.",
rules = {
"별도의 전투 수치 보정이 없습니다.",
},
},
},
cardTypes = {
action = {
id = "action",
label = "행동",
tooltip = "주 행동을 소비하고 즉시 해결하는 카드",
},
chain = {
id = "chain",
label = "연계",
tooltip = "주 행동을 소비하지 않는 보조 카드",
},
plan = {
id = "plan",
label = "계획",
tooltip = "계획 슬롯에 배치되어 조건을 만족하면 발동하는 카드",
},
},
roles = {
pressure = {
id = "pressure",
owner = "player",
label = "압박",
tooltip = "무드 토큰을 생성·제거하거나 무드를 강제로 바꾸는 행동",
},
deception = {
id = "deception",
owner = "player",
label = "기만",
tooltip = "은폐를 회복해 행동을 숨기거나 의심을 피하는 행동",
},
violation = {
id = "violation",
owner = "player",
label = "유린",
tooltip = "캐릭터에게 저항 피해를 주는 행동",
},
response = {
id = "response",
owner = "character",
label = "대응",
tooltip = "무드 토큰을 생성·제거하거나 무드를 강제로 바꾸는 행동",
},
exposure = {
id = "exposure",
owner = "character",
label = "폭로",
tooltip = "플레이어에게 은폐 피해를 주는 행동",
},
recovery = {
id = "recovery",
owner = "character",
label = "회복",
tooltip = "캐릭터의 저항을 회복하는 행동",
},
},
mechanisms = {
remove = {
id = "remove",
label = "소멸",
tooltip = "사용 후 이번 세션의 활성 카드 영역에서 제외",
},
insight = {
id = "insight",
label = "간파",
tooltip = "이 카드 때문에 발동할 상대 계획을 이번 해결에서 억제",
},
},
ruleTerms = {
manipulate = {
id = "manipulate",
label = "농락",
tooltip = "현재 무드보다 한 단계 긍정적인 무드에 토큰을 표시된 수만큼 추가합니다. 이미 순응이라면 순응 토큰을 추가합니다.",
},
backlash = {
id = "backlash",
label = "반발",
tooltip = "현재 무드보다 한 단계 부정적인 무드에 토큰을 표시된 수만큼 추가합니다. 이미 거절이라면 거절 토큰을 추가합니다.",
},
calm = {
id = "calm",
label = "진정",
tooltip = "현재 무드보다 한 단계 부정적인 무드의 토큰을 표시된 수만큼 제거합니다. 이미 거절이라면 거절 토큰을 제거합니다.",
},
cool = {
id = "cool",
label = "냉각",
tooltip = "현재 무드보다 한 단계 긍정적인 무드의 토큰을 표시된 수만큼 제거합니다. 이미 순응이라면 순응 토큰을 제거합니다.",
},
},
moods = {
rejection = { id = "rejection", label = "거절", order = 1 },
suspicion = { id = "suspicion", label = "의심", order = 2 },
ignore = { id = "ignore", label = "무시", order = 3 },
confusion = { id = "confusion", label = "혼란", order = 4 },
compliance = { id = "compliance", label = "순응", order = 5 },
},
events = {
session_start = { id = "session_start" },
turn_start = { id = "turn_start" },
role_revealed = { id = "role_revealed" },
card_declared = { id = "card_declared" },
card_resolved = { id = "card_resolved" },
turn_end = { id = "turn_end" },
session_end = { id = "session_end" },
},
effectOps = {
damage_resistance = { id = "damage_resistance" },
recover_resistance = { id = "recover_resistance" },
lose_stealth = { id = "lose_stealth" },
recover_stealth = { id = "recover_stealth" },
draw_cards = { id = "draw_cards" },
skip_actions = { id = "skip_actions" },
add_mood_token = { id = "add_mood_token" },
remove_mood_token = { id = "remove_mood_token" },
force_mood = { id = "force_mood" },
},
}
#5PlayerCards.db
Keys:
local MOOD_ORDER = { "rejection", "suspicion", "ignore", "confusion", "compliance" }
local MOOD_INDEX = {}
for index, moodId in ipairs(MOOD_ORDER) do MOOD_INDEX[moodId] = index end
local function command(op, target, amount, mood, cause)
local value = { op = op, target = target, cause = cause or "cardEffect" }
if amount ~= nil then value.amount = amount end
if mood ~= nil then value.mood = mood end
return value
end
local function damage(amount, cause)
return command("damage_resistance", "character", amount, nil, cause)
end
local function recoverStealth(amount, cause)
return command("recover_stealth", "player", amount, nil, cause)
end
local function addMood(mood, amount, cause)
return command("add_mood_token", "character", amount, mood, cause)
end
local function removeMood(mood, amount, cause)
return command("remove_mood_token", "character", amount, mood, cause)
end
local function forceMood(mood, cause)
return command("force_mood", "character", nil, mood, cause)
end
local function adjacentMood(currentMood, delta)
local index = MOOD_INDEX[currentMood]
if index == nil then return currentMood end
local target = index + delta
if target < 1 then target = 1 end
if target > #MOOD_ORDER then target = #MOOD_ORDER end
return MOOD_ORDER[target]
end
local function manipulate(context, amount, cause)
return addMood(adjacentMood(context.mood, 1), amount, cause)
end
local function backlash(context, amount, cause)
return addMood(adjacentMood(context.mood, -1), amount, cause)
end
local function calm(context, amount, cause)
return removeMood(adjacentMood(context.mood, -1), amount, cause)
end
local function cool(context, amount, cause)
return removeMood(adjacentMood(context.mood, 1), amount, cause)
end
local function negative(mood)
return mood == "rejection" or mood == "suspicion"
end
local function positive(mood)
return mood == "confusion" or mood == "compliance"
end
local function hasRole(roles, roleId)
for _, id in ipairs(roles or {}) do
if id == roleId then return true end
end
return false
end
local cards = {}
local function add(spec)
local cardType = spec.cardType or "action"
local mechanisms = spec.mechanisms or {}
local value = {
id = spec.id,
owner = "player",
name = spec.name,
rarity = spec.rarity,
description = spec.description,
cardType = cardType,
roles = spec.roles,
mechanisms = mechanisms,
base = { stealthCost = spec.cost, resistanceDamage = spec.damage },
rules = spec.rules or { "기본 수치만 적용." },
}
if spec.narration ~= nil then
value.narration = spec.narration
elseif cardType == "plan" then
value.narration = {
planPlaced = { actorAction = spec.placedAction },
planTriggered = { actorAction = spec.triggeredAction },
}
else
local firstChoice = type(spec.effectChoices) == "table" and spec.effectChoices[1] or nil
value.narration = { play = { actorAction = spec.actorAction or (firstChoice and firstChoice.actorAction) } }
end
if cardType == "plan" and spec.plan ~= nil then
value.mechanismData = { plan = spec.plan }
end
if spec.resolve ~= nil then value.resolve = spec.resolve end
if spec.canPlay ~= nil then value.canPlay = spec.canPlay end
if spec.effectChoices ~= nil then value.effectChoices = spec.effectChoices end
if spec.selectionPreview ~= nil then value.selectionPreview = spec.selectionPreview end
cards[spec.id] = value
end
-- =========================================================================
-- 일반 행동 카드 (18종)
-- =========================================================================
add({
id = "pc_c01_standard",
name = "자지 밀착",
rarity = "common",
roles = { "pressure", "violation" },
cost = 1,
damage = 3,
rules = { "농락 1." },
description = "열차 흔들림을 틈타 등 뒤로 파고들어, 발기한 자지를 엉덩이골 사이에 깊숙이 끼워 넣고 끈적하게 비벼댑니다.",
actorAction = '"사람이 많아서 자꾸 밀착되네요... 닿아도 조금만 참아줘요♥" 귓가에 뜨거운 입김을 훅 불어넣으며, 옷 너머로 단단하게 솟구친 자지를 말랑한 엉덩이골 틈새에 빈틈없이 끼워 넣고 골반을 은밀하게 돌린다.',
resolve = function(context) return { manipulate(context, 1) } end,
})
add({
id = "pc_c02_safe_damage",
name = "엉덩이 주무름",
rarity = "common",
roles = { "violation" },
cost = 0,
damage = 3,
description = "들키지 않게 팔을 아래로 늘어뜨려, 풍만한 곡선의 말랑한 엉덩이 살결을 손바닥 전체로 감싸 쥐고 깊숙이 주무릅니다.",
actorAction = "딴청을 피우듯 시선은 허공을 향한 채, 등 뒤로 내린 손으로 보드라운 엉덩이 살결을 한 손 가득 감싸 쥐어 손바닥 안에서 풍만한 곡선이 뭉개지도록 주무른다.",
})
add({
id = "pc_c03_heavy_damage",
name = "가슴 움켜쥐기",
rarity = "common",
roles = { "violation" },
cost = 1,
damage = 4,
description = "사각에서 상체를 밀착해, 옷 위로 탐스럽게 부풀어 오른 가슴을 한 손 가득 덥석 감싸 쥐고 부드럽게 주무릅니다.",
actorAction = "몸을 바짝 겹치며 옷 너머로 풍만하게 솟은 가슴을 덥석 감싸 쥔다. 손바닥 안에서 짓눌리며 뭉개지는 묵직하고 말랑한 감촉을 즐기며 손끝으로 유두 부근을 집요하게 둥글리며 야릇하게 매만진다.",
})
add({
id = "pc_c11_restricted_safe",
name = "거부감 능욕",
rarity = "common",
roles = { "pressure", "violation" },
cost = 0,
damage = 3,
rules = { "의심 또는 거절일 때만 사용 가능.", "농락 1." },
description = "경계심에 굳어있는 목덜미에 입술을 묻고 몸을 굳힐수록 더 닿는다고 속삭이며, 의심과 거절을 쾌락으로 뒤집습니다.",
actorAction = '"그렇게 힘을 주고 버티면... 자지가 더 깊숙이 닿는 거 몰라요? 읏, 살결이 이렇게 말랑하게 감겨오니 참기 힘드네♥" 굳어있는 목덜미에 끈적하게 입을 맞추며 가랑이를 지그시 밀착한다.',
canPlay = function(context) if negative(context.mood) then return true end return false, "requires_negative_mood" end,
resolve = function(context) return { manipulate(context, 1) } end,
})
add({
id = "pc_c12_conditional_cost",
name = "맨살 더듬기",
rarity = "common",
roles = { "pressure", "violation" },
cost = 2,
damage = 3,
rules = { "농락 1.", "혼란 또는 순응이면 추가 저항 피해 2." },
description = "옷자락 틈으로 손을 슬그머니 밀어 넣어, 허리에서 엉덩이로 이어지는 보드라운 맨살을 직접 쓰다듬으며 쾌감을 증폭시킵니다.",
actorAction = "옷자락 틈새로 뜨거운 손을 찔러 넣어 허리와 골반의 보드라운 맨살을 직접 손바닥으로 감싸 쥔다. 매끄러운 살결을 엄지손가락으로 둥글게 어루만지며 손끝을 사타구니 안쪽으로 미끄러뜨린다.",
resolve = function(context) local result = { manipulate(context, 1) }; if positive(context.mood) then result[#result + 1] = damage(2) end return result end,
})
add({
id = "pc_c13_conditional_damage",
name = "골반안쪽 희롱",
rarity = "common",
roles = { "pressure", "violation" },
cost = 1,
damage = 2,
rules = { "농락 1.", "혼란 또는 순응이면 추가 저항 피해 2." },
description = "양손으로 가녀린 골반을 감싸 쥐고, 엄지손가락으로 사타구니 안쪽을 지그시 문질러 전신에 음란한 전율을 퍼뜨립니다.",
actorAction = "잘록한 골반을 양손으로 단단히 붙잡아 자신의 발기한 가랑이 쪽으로 바짝 끌어당긴다. 엄지손가락으로 허벅지와 골반이 이어지는 보드라운 안쪽 살결을 지그시 누르며 농염하게 매만진다.",
resolve = function(context) local result = { manipulate(context, 1) }; if positive(context.mood) then result[#result + 1] = damage(2) end return result end,
})
add({
id = "pc_c14_conditional_mood",
name = "가랑이 압박",
rarity = "common",
roles = { "violation" },
cost = 1,
damage = 3,
rules = { "혼란 또는 순응이면 추가 저항 피해 2." },
description = "허벅지 사이에 한쪽 무릎을 단단히 밀어 넣고, 다리를 벌리게 만들며 가랑이 중심부를 무릎으로 은근히 비벼 올립니다.",
actorAction = "다리 사이 틈새로 무릎을 비집고 밀어 넣어 가랑이 밑부분을 묵직하게 받쳐 올린다. 체중을 실어 무릎으로 은밀한 사타구니 부위를 지그시 누르며 비벼대어 깊은 쾌감을 강요한다.",
resolve = function(context) if positive(context.mood) then return { damage(2) } end return {} end,
})
add({
id = "pc_c15_conditional_turn",
name = "농익은 유린",
rarity = "common",
roles = { "pressure", "violation" },
cost = 2,
damage = 3,
rules = { "농락 1.", "6턴 이후면 추가 저항 피해 2." },
description = ""슬슬 몸도 쾌락에 익숙해졌죠?"라며 상하체를 거침없이 ::tag[violation]::해, 길들여진 육체를 완전히 무너뜨립니다.",
actorAction = '"슬슬 몸도 솔직하게 쑤셔오는 모양인데... 더는 못 참겠죠?♥" 한 손으로는 가슴의 풍만한 곡선을 부드럽게 감싸 쥐어 매만지고 다른 한 손으로는 엉덩이를 주무르며, 팽창한 자지를 엉덩이골에 은근슬쩍 끈적하게 문질러댄다.',
resolve = function(context) local result = { manipulate(context, 1) }; if context.turn >= 6 then result[#result + 1] = damage(2) end return result end,
})
add({
id = "pc_c16_conditional_resistance",
name = "민감한 급소",
rarity = "common",
roles = { "pressure", "violation" },
cost = 1,
damage = 2,
rules = { "농락 1.", "잔여 저항 18 이하면 추가 저항 피해 2." },
description = "저항력이 무너져 내린 틈을 타, 가장 민감하고 야릇한 부위에 손가락 끝을 집중시켜 이성을 완전히 끊어버립니다.",
actorAction = "저항이 헐거워진 틈을 놓치지 않고, 가랑이 중앙의 가장 은밀하고 민감한 돌기 부위를 옷 너머로 손끝을 세워 지그시 누른 뒤 자잘하게 튕겨내듯 매만진다.",
resolve = function(context) local result = { manipulate(context, 1) }; if context.character.resistance <= 18 then result[#result + 1] = damage(2) end return result end,
})
add({
id = "pc_c17_conditional_stealth",
name = "대담한 손길",
rarity = "common",
roles = { "violation" },
cost = 1,
damage = 3,
rules = { "잔여 은폐 5 이하면 추가 저항 피해 2." },
description = "주변의 시선이 코앞까지 다가온 아슬아슬한 스릴 속에서, 보란 듯이 엉덩이를 그러쥐고 가랑이를 비벼대는 대담한 ::tag[violation]:: 행동입니다.",
actorAction = "주변 사람들에게 들킬 듯한 아슬아슬한 긴장감 속에서, 오히려 더 거칠게 엉덩이의 풍만한 곡선을 양손으로 감싸 쥐고 발기한 자지를 엉덩이골 사이에 사정없이 짓누르며 뜨거운 쾌락을 깊숙이 끌어올린다.",
resolve = function(context) if context.player.stealth <= 5 then return { damage(2) } end return {} end,
})
add({
id = "pc_c18_conditional_role",
name = "방어 무력화",
rarity = "common",
roles = { "pressure", "violation" },
cost = 1,
damage = 2,
rules = { "농락 1.", "공개 역할이 대응 또는 회복이면 추가 저항 피해 2." },
description = "방어하고 물러서려는 움직임의 반동을 이용해 골반을 단숨에 낚아채고 자지를 더 깊숙이 박아넣는 카운터 ::tag[violation]:: 행동입니다.",
actorAction = "몸을 빼내려는 찰나의 빈틈을 파고들어 허리를 낚아채 제 가랑이 쪽으로 강하게 끌어당긴다. 도망치지 못하도록 골반을 밀착한 채 사타구니 안쪽의 말랑한 살결을 억세게 주무른다.",
resolve = function(context) local result = { manipulate(context, 1) }; if context.character.publicRole == "response" or context.character.publicRole == "recovery" then result[#result + 1] = damage(2) end return result end,
})
-- =========================================================================
-- 일반 회복(Recovery) 카드 (7종)
-- =========================================================================
add({
id = "pc_c19_recovery_mood",
name = "뻔뻔한 입김",
rarity = "common",
roles = { "deception", "pressure" },
cost = 0,
damage = 0,
rules = { "은폐 4 회복.", "농락 1." },
description = "밀리는 인파 핑계를 대며 시치미를 떼면서도, 귓가에 야릇한 뜨거운 숨결을 뿜어 농락하고 은폐를 정돈합니다.",
actorAction = '"죄송합니다, 뒤에서 갑자기 밀려서... 괜찮으세요?♥" 공손하게 사과하는 척 얼굴을 귓가에 바짝 붙이고, 귓구멍에 뜨거운 숨을 후 불어넣으며 태연하게 자세를 바로잡는다.',
resolve = function(context) return { recoverStealth(4), manipulate(context, 1) } end,
})
add({
id = "pc_c20_recovery_damage",
name = "딴청 희롱",
rarity = "common",
roles = { "deception", "violation" },
cost = 0,
damage = 1,
rules = { "은폐 4 회복." },
description = "시선은 허공을 바라보며 사각에서 엉덩이 밑살을 손끝으로 은밀히 매만져 은폐를 챙기고 피해를 줍니다.",
actorAction = "무심한 표정으로 차창 밖을 응시하면서, 등 뒤로 내린 손가락 끝으로 엉덩이 밑살의 말랑한 곡선을 살짝 어루만져 매만지고 재빨리 손을 거둔다.",
resolve = function() return { recoverStealth(4) } end,
})
add({
id = "pc_c21_recovery_pressure",
name = "사각 귓속말",
rarity = "common",
roles = { "deception", "pressure" },
cost = 0,
damage = 0,
rules = { "은폐 3 회복.", "농락 2." },
description = "다른 승객의 등 뒤 사각으로 몸을 교묘히 숨기며, 귓가에 끈적한 음담패설을 속삭여 상대를 음란하게 달아오르게 만듭니다.",
actorAction = '승객들의 등 뒤 사각으로 미끄러져 들어가 모습을 감추며, 귓가에 입술을 대고 "아까 닿았던 감촉, 아직도 화끈거리죠?♥"라며 낮고 음란하게 속삭인다.',
resolve = function(context) return { recoverStealth(3), manipulate(context, 2) } end,
})
add({
id = "pc_c22_recovery_calm",
name = "능청스런 거리",
rarity = "common",
roles = { "deception", "pressure" },
cost = 0,
damage = 0,
rules = { "은폐 4 회복.", "진정 2." },
description = ""죄송합니다"라며 두 손을 들고 뻔뻔하게 한 걸음 물러서서 상대의 경계심을 완전히 가라앉히고 은폐를 확보합니다.",
actorAction = '"어라, 닿았나요? 오해하지 마세요, 흔들려서 그런 거니까요." 양손을 떼어 들며 결백한 척 물러선다. 상대가 경계를 풀고 안도하는 틈을 타 완벽한 사각을 확보한다.',
resolve = function(context) return { recoverStealth(4), calm(context, 2) } end,
})
add({
id = "pc_c23_recovery_conditional",
name = "공손한 핑계",
rarity = "common",
roles = { "deception", "pressure" },
cost = 0,
damage = 0,
rules = { "은폐 3 회복.", "농락 1.", "의심 또는 거절이면 추가로 은폐 2 회복." },
description = "정중한 말투로 사과하며 시치미를 떼어 불리한 무드를 완화하고 대폭 은폐를 회복하는 고급 ::tag[deception]:: 행동입니다.",
actorAction = '"차가 너무 흔들리네요, 불편하셨다면 죄송합니다." 예의 바른 태도로 물러서는 척하며, 등 뒤의 사각을 확보해 다음 손길을 뻗을 완벽한 위치를 잡는다.',
resolve = function(context) local result = { recoverStealth(3), manipulate(context, 1) }; if negative(context.mood) then result[#result + 1] = recoverStealth(2) end return result end,
})
add({
id = "pc_c24_recovery_chain_mood",
name = "목덜미 숨결",
rarity = "common",
cardType = "chain",
roles = { "deception", "pressure" },
cost = 0,
damage = 0,
rules = { "은폐 2 회복.", "농락 1." },
description = "행동 사이의 찰나에 목덜미에 코끝을 묻고 뜨거운 숨을 길게 내쉬어 야릇한 자극을 남기는 ::tag[chain]:: ::tag[deception]:: 행동입니다.",
actorAction = "행동이 이어지는 찰나 목덜미 뒤편에 코끝을 바짝 대고 뜨거운 숨을 깊게 들이쉬었다 내뱉는다. 보드라운 살결에 소름이 돋는 것을 느끼며 자연스럽게 몸을 숨긴다.",
resolve = function(context) return { recoverStealth(2), manipulate(context, 1) } end,
})
add({
id = "pc_c25_recovery_chain_damage",
name = "엉덩이 스침",
rarity = "common",
cardType = "chain",
roles = { "deception", "violation" },
cost = 0,
damage = 1,
rules = { "은폐 2 회복." },
description = "몸을 스쳐 지나가는 척하며 손등으로 엉덩이 살결을 슥 문지르고 지나가는 은밀한 ::tag[chain]:: ::tag[violation]:: 행동입니다.",
actorAction = "자세를 고쳐잡는 척 몸을 살짝 비틀면서, 손등으로 엉덩이골의 풍만한 곡선을 슥 쓸어내려 자극을 주고 태연하게 손을 감춘다.",
resolve = function() return { recoverStealth(2) } end,
})
-- =========================================================================
-- 일반 선택·연계·계획 카드 (10종)
-- =========================================================================
add({
id = "pc_c26_choice_control",
name = "완급조절 희롱",
rarity = "common",
roles = { "pressure", "violation" },
cost = 0,
damage = 2,
rules = { "다음 중 하나를 선택: 농락 1 / 진정 2." },
description = "상대를 더 음란하게 자극할지, 상냥하게 달래어 경계심을 풀어낼지 손길의 완급을 선택하는 테크닉입니다.",
effectChoices = {
{
id = "manipulate_one",
label = "농락 애무",
description = "농락 1.",
actorAction = '"그렇게 파르르 떨면... 더 괴롭히고 싶어지잖아요♥" 귓가에 속삭이며 단단한 자지를 엉덩이에 지그시 밀어붙여 골반을 끈적하게 돌린다.',
},
{
id = "calm_two",
label = "달래기",
description = "진정 2.",
actorAction = '"쉬이... 괜찮아요, 무서워할 거 없어요...♥" 다정하게 등을 쓸어내리며 안심시키듯 살살 어루만져 몸의 힘을 풀게 만든다.',
},
},
resolve = function(context) if context.effectChoiceId == "calm_two" then return { calm(context, 2, "cardChoice") } end return { manipulate(context, 1, "cardChoice") } end,
})
add({
id = "pc_c27_choice_confusion",
name = "이성마비 유도",
rarity = "common",
roles = { "pressure", "violation" },
cost = 0,
damage = 2,
rules = { "다음 중 하나를 선택: 농락 1 / 혼란 토큰 1개 추가." },
description = "귓가에 음담패설을 쏟아붓거나 손끝으로 민감한 신경을 지압해 이성을 마비시키는 선택형 ::tag[violation]:: 행동입니다.",
effectChoices = {
{
id = "manipulate_one",
label = "음탕 속삭임",
description = "농락 1.",
actorAction = '"입으로는 싫다고 하면서 몸은 벌써 이렇게 달아올랐네요?♥" 귓불을 잘근잘근 깨물며 발기한 자지를 엉덩이골에 단단히 밀착한다.',
},
{
id = "confusion_one",
label = "급소 지압",
description = "혼란 토큰 1개 추가.",
actorAction = '"어디가 만져지는지... 머릿속이 새하얘지죠?♥" 옷 너머로 가장 민감한 가랑이의 말랑한 살결을 손가락 끝으로 지그시 누르며 오싹한 감각을 찔러 넣는다.',
},
},
resolve = function(context) if context.effectChoiceId == "confusion_one" then return { addMood("confusion", 1, "cardChoice") } end return { manipulate(context, 1, "cardChoice") } end,
})
add({
id = "pc_c28_chain_standard",
name = "자지 비벼대기",
rarity = "common",
cardType = "chain",
roles = { "pressure", "violation" },
cost = 1,
damage = 1,
rules = { "농락 1." },
description = "열차의 흔들림에 맞춰 자지를 엉덩이골에 계속해서 끈적하게 비벼대는 기본 ::tag[chain]:: 행동입니다.",
actorAction = "덜컹이는 열차의 리듬에 골반을 맞추어, 팽창한 자지를 엉덩이골 사이에 끼워 넣고 은근슬쩍 앞뒤로 집요하게 비벼댄다.",
resolve = function(context) return { manipulate(context, 1) } end,
})
add({
id = "pc_c29_chain_cost",
name = "기습 엉덩이",
rarity = "common",
cardType = "chain",
roles = { "pressure", "violation" },
cost = 3,
damage = 3,
rules = { "농락 1." },
description = "주 행동에 틈을 주지 않고 양손으로 엉덩이를 통째로 감싸 쥐어 가랑이로 당기는 강력한 ::tag[chain]:: ::tag[violation]::입니다.",
actorAction = "순간적으로 몸을 겹치며 양손으로 풍만한 엉덩이 곡선을 가득 감싸 쥔다. 손가락을 말랑한 살결에 깊숙이 파묻으며 자신의 발기한 자지 쪽으로 힘껏 끌어당긴다.",
resolve = function(context) return { manipulate(context, 1) } end,
})
add({
id = "pc_c30_chain_backlash",
name = "거친 손아귀",
rarity = "common",
cardType = "chain",
roles = { "pressure", "violation" },
cost = 1,
damage = 3,
rules = { "농락 1.", "반발 2." },
description = "엉덩이와 허벅지 살을 깊숙이 감싸 쥐며 주물러, 강렬한 쾌감과 거센 수치심의 반발을 유발하는 ::tag[chain]:: 행동입니다.",
actorAction = "손가락에 힘을 주어 엉덩이의 풍만한 살결을 손바닥 가득 감싸 쥐어 집요하게 주무른다. 옷 너머로 손자국이 남을 만큼 거칠게 움켜쥐며 골반을 강하게 들이받는다.",
resolve = function(context) return { manipulate(context, 1), backlash(context, 2) } end,
})
add({
id = "pc_c31_chain_draw",
name = "유린 구상",
rarity = "common",
cardType = "chain",
roles = { "violation" },
cost = 2,
damage = 1,
rules = { "선택 단계에 카드를 1장 즉시 뽑음." },
description = "손끝으로 척추선을 따라 어루만지며, 다음에 쏟아부을 더 음란하고 가학적인 애무를 머릿속에 그리는 ::tag[chain]:: 행동입니다.",
actorAction = "손가락 끝으로 등을 따라 척추선을 살살 쓸어내린다. 손바닥에 닿는 보드라운 살결의 떨림을 음미하며, 다음에는 어디를 어떻게 유린할지 음란한 계획을 떠올린다.",
selectionPreview = { effects = { { id = "draw_one", op = "draw_cards", target = "player", amount = 1 } } },
})
add({
id = "pc_c32_chain_insight",
name = "반항 낌새차단",
rarity = "common",
cardType = "chain",
roles = { "violation" },
mechanisms = { "insight" },
cost = 1,
damage = 1,
rules = { "저항 피해 1.", "이 카드 때문에 발동할 상대 계획을 억제." },
description = "상대를 움직이지 못하게 옆구리와 허리를 바짝 감싸 쥐어 저항 계획을 무력화하는 ::tag[insight]:: ::tag[chain]:: 행동입니다.",
actorAction = "상대가 저항하려는 낌새를 눈치채고 재빨리 한 팔로 허리를 감싸 안아 도망가지 못하게 밀착한다. 다른 손으로 옆구리를 지그시 누르며 반항을 사전에 봉쇄한다.",
})
add({
id = "pc_c33_plan_recovery",
name = "쾌락의 덫",
rarity = "common",
cardType = "plan",
roles = { "deception", "pressure" },
cost = 1,
damage = 0,
rules = { "배치 시 농락 1.", "지속 3턴, 충전 2.", "혼란 또는 순응 상태의 턴 시작에 은폐 2 회복." },
description = "귓가에 야릇한 기대를 심어두고, 쾌락에 취해 멍해진 틈을 타 은폐를 지속적으로 챙기는 ::tag[plan]:: 카드입니다.",
placedAction = '"몸이 이렇게 뜨거운데... 앞으로 어떻게 될지 기대되죠?♥" 귓가에 은밀한 약속을 속삭이며 골반을 부드럽게 감싸 안는다.',
triggeredAction = "쾌락에 정신이 팔려 주위를 살피지 못하는 틈을 타, 여유롭게 자세를 정돈하고 완벽한 사각에 숨어든다.",
resolve = function(context) return { manipulate(context, 1) } end,
plan = { event = "turn_start", durationTurns = 3, charges = 2, trigger = function(context, event) return event.type == "turn_start" and positive(context.mood) end, resolve = function() return { recoverStealth(2, "plan") } end },
})
add({
id = "pc_c34_plan_ignore_damage",
name = "급소 침략",
rarity = "common",
cardType = "plan",
roles = { "violation" },
cost = 1,
damage = 0,
rules = { "지속 3턴, 충전 1.", "무시 상태의 턴 시작에 저항 피해 5." },
description = "무표정으로 애써 버티는 몸의 틈새를 노려, 한순간에 급소를 감싸 쥐어 저항을 박살내는 치명적인 ::tag[plan]:: 카드입니다.",
placedAction = "입술을 지그시 깨물고 모른 척 버티는 모습을 내려다보며, 손끝을 가랑이 바로 앞까지 은밀히 가져다 댄다.",
triggeredAction = "참고 있던 틈을 타 가랑이 중앙의 급소를 한 손으로 틀어쥔다! 비순의 돌기를 손끝으로 지그시 누르며 매만져 비명을 삼키게 만든다.",
plan = { event = "turn_start", durationTurns = 3, charges = 1, trigger = function(context, event) return event.type == "turn_start" and context.mood == "ignore" end, resolve = function() return { damage(5, "plan") } end },
})
add({
id = "pc_c35_plan_compliance_role",
name = "쾌락굴레 조교",
rarity = "common",
cardType = "plan",
roles = { "pressure", "violation" },
cost = 1,
damage = 0,
rules = { "배치 시 농락 1.", "지속 3턴, 충전 2.", "순응에서 플레이어의 유린 카드 해결 뒤 저항 피해 3." },
description = "완전히 굴복한 몸에 손길을 더할 때마다 쾌락이 연쇄적으로 터져 나오도록 각인시키는 ::tag[plan]:: 카드입니다.",
placedAction = '"이제 거부하지 말고 손길을 온전히 느껴봐요... 몸이 원하는 대로 해줄 테니까♥" 엉덩이의 풍만한 곡선을 부드럽게 쓰다듬는다.',
triggeredAction = "유린의 손길이 닿자마자 손끝으로 쾌락의 급소를 연쇄적으로 짓눌러, 전신에 오싹한 쾌락의 전류를 내달리게 만든다.",
resolve = function(context) return { manipulate(context, 1) } end,
plan = { event = "card_resolved", side = "player", durationTurns = 3, charges = 2, trigger = function(context, event) return event.type == "card_resolved" and event.side == "player" and hasRole(event.roles, "violation") and context.mood == "compliance" end, resolve = function() return { damage(3, "plan") } end },
})
-- =========================================================================
-- 희귀(Rare) 카드 (12종)
-- =========================================================================
add({
id = "pc_r01_cost_upgrade",
name = "노련한 밀착",
rarity = "rare",
roles = { "pressure", "violation" },
cost = 0,
damage = 3,
rules = { "농락 1." },
description = "아무런 기척도 없이 등 뒤로 스며들어, 단단해진 자지를 엉덩이에 깊숙이 파묻고 손바닥 가득 주무르는 노련한 치한 테크닉입니다.",
actorAction = "한 치의 군더더기 없는 움직임으로 사각에 밀착해, 발기한 자지를 엉덩이골 사이에 깊숙이 찔러 넣고 양손으로 풍만한 엉덩이 살결을 손바닥 가득 감싸 쥐어 주무른다.",
resolve = function(context) return { manipulate(context, 1) } end,
})
add({
id = "pc_r03_mood_upgrade",
name = "자지 끈적마찰",
rarity = "rare",
roles = { "pressure", "violation" },
cost = 1,
damage = 3,
rules = { "농락 2." },
description = "터질 듯한 자지를 엉덩이골 틈새에 완벽히 끼워 넣고, 은근슬쩍 집요하게 골반을 돌려 단번에 음란하게 타락시킵니다.",
actorAction = '"뒤에서 닿는 이 뜨거운 거... 어떤 모양인지 다 느껴지죠?♥" 팽창한 자지를 엉덩이골에 끼워 맞추고, 골반을 묵직하고 끈적하게 돌리며 사정없이 문질러댄다.',
resolve = function(context) return { manipulate(context, 2) } end,
})
add({
id = "pc_r04_recovery_upgrade",
name = "완벽한 사각",
rarity = "rare",
roles = { "deception", "pressure" },
cost = 0,
damage = 0,
rules = { "은폐 5 회복.", "농락 1." },
description = "인파 속에 완벽히 녹아들어 흔적을 지우면서, 목덜미에 입을 맞추고 농락을 이어가는 최상급 ::tag[deception]:: 기술입니다.",
actorAction = "주변 승객들의 시야에서 완벽히 몸을 숨기며 등 뒤로 밀착한다. 목덜미의 보드라운 살결에 입술을 살포시 대고 뜨거운 숨을 불어넣으며 다음 손길을 준비한다.",
resolve = function(context) return { recoverStealth(5), manipulate(context, 1) } end,
})
add({
id = "pc_r05_recovery_damage",
name = "은밀한 가슴",
rarity = "rare",
roles = { "deception", "violation" },
cost = 0,
damage = 2,
rules = { "은폐 4 회복." },
description = "그 누구에게도 들키지 않는 완벽한 사각에서 옷 위로 가슴을 받쳐 올려 부드럽게 매만지며 은폐와 피해를 동시에 거두는 행동입니다.",
actorAction = "시선은 태연하게 정면을 향한 채, 사각에서 뻗은 손으로 옷 너머 풍만한 가슴의 말랑한 곡선을 아래에서부터 받쳐 올려 손바닥 가득 부드럽게 주무르고 싹 빠져나온다.",
resolve = function() return { recoverStealth(4) } end,
})
add({
id = "pc_r06_force_conditional",
name = "협박의 속삭임",
rarity = "rare",
roles = { "pressure", "violation" },
cost = 1,
damage = 2,
rules = { "의심 또는 거절이면 턴 종료 무드를 무시로 강제 변경." },
description = ""소리치면 여기서 다 보여줄 뿐이야"라며 귓가에 위협하듯 속삭여, 거센 저항과 의심을 얼어붙게 만드는 강제 무드 행동입니다.",
actorAction = '"소리 내서 사람들을 부르면... 당신이 어떤 꼴로 당하고 있었는지 다 알게 될 텐데? 얌전히 있는 게 좋을 텐데♥" 귓가에 낮게 읊조리며 허리를 거칠게 낚아챈다.',
resolve = function(context) if negative(context.mood) then return { forceMood("ignore") } end return {} end,
})
add({
id = "pc_r07_force_unconditional",
name = "이성붕괴 비순",
rarity = "rare",
roles = { "pressure", "violation" },
cost = 1,
damage = 1,
rules = { "턴 종료 무드를 혼란으로 강제 변경." },
description = "가랑이 중심부의 급소를 불시에 손바닥으로 감싸 쥐어, 전신에 오싹한 쾌락의 번개를 떨어뜨려 머릿속을 새하얗게 비우는 행동입니다.",
actorAction = "손을 단숨에 다리 사이로 찔러 넣어 옷 너머 은밀한 돌기를 손가락 끝으로 강하게 비틀어 짓누른다! 한순간에 머릿속의 이성을 끊어버리는 찌릿한 쾌감을 강제로 주입한다.",
resolve = function() return { forceMood("confusion") } end,
})
add({
id = "pc_r08_payoff_remove",
name = "함락의 절정",
rarity = "rare",
roles = { "violation" },
mechanisms = { "remove" },
cost = 5,
damage = 7,
rules = { "순응이면 추가 저항 피해 5.", "사용 후 소멸." },
description = "순응하여 저항을 잃은 몸을 덮치듯 밀착해, 옷 속으로 손을 깊숙이 파묻고 온몸을 끈적하게 ::tag[violation]::한 뒤 ::tag[remove]::하는 궁극의 피니셔 카드입니다.",
actorAction = '"더 이상 참을 필요 없어요... 그대로 녹아내려요♥" 순응해 힘이 풀린 몸을 뒤에서 완전히 덮쳐 안고, 옷 속으로 양손을 찔러 넣어 가슴과 엉덩이의 풍만한 곡선을 손바닥 가득 감싸 쥐고 탐스럽게 주무르며 맹렬하게 유린한다.',
resolve = function(context) if context.mood == "compliance" then return { damage(5) } end return {} end,
})
add({
id = "pc_r09_payoff_insight",
name = "심연의 능욕",
rarity = "rare",
roles = { "pressure", "violation" },
mechanisms = { "insight" },
cost = 4,
damage = 6,
rules = { "혼란이면 추가 저항 피해 2.", "순응이면 추가 저항 피해 5 후 냉각 1.", "이 카드 때문에 발동할 상대 계획을 억제." },
description = "상대의 방어를 완벽히 ::tag[insight]::하고 가랑이와 엉덩이에 자지와 손길을 동시에 박아넣어 저항을 산산조각 냅니다.",
actorAction = "상대의 방어 의도를 완벽히 읽고 그 틈새로 파고든다. 단단한 자지를 엉덩이골에 빈틈없이 처박고, 손가락으로 가랑이 중심부를 깊숙이 짓눌러 온몸이 무너지도록 희롱한다.",
resolve = function(context) if context.mood == "compliance" then return { damage(5), cool(context, 1) } end if context.mood == "confusion" then return { damage(2) } end return {} end,
})
add({
id = "pc_r10_chain_draw",
name = "절정충동 자극",
rarity = "rare",
cardType = "chain",
roles = { "violation" },
cost = 1,
damage = 1,
rules = { "선택 단계에 카드를 1장 즉시 뽑음." },
description = "살결의 떨림을 손끝으로 매만지며, 다음 순번에 퍼부을 쾌락의 연타를 준비하는 ::tag[chain]:: 드로우 행동입니다.",
actorAction = "등허리를 손끝으로 둥글게 매만지며 미세하게 떨려오는 보드라운 피부의 감촉을 음미한다. 다음에 퍼부을 더 야하고 잔인한 손길을 머릿속에 장전한다.",
selectionPreview = { effects = { { id = "draw_one", op = "draw_cards", target = "player", amount = 1 } } },
})
add({
id = "pc_r11_choice_compliance",
name = "암컷본능 조교",
rarity = "rare",
roles = { "pressure", "violation" },
cost = 0,
damage = 2,
rules = { "다음 중 하나를 선택: 농락 1 / 순응 토큰 1개 추가." },
description = "귓가에 음란한 조롱을 불어넣거나 가랑이를 완전히 밀착해, 스스로 몸을 내맡기도록 조교하는 강력한 선택형 행동입니다.",
effectChoices = {
{
id = "manipulate_one",
label = "본능 자극",
description = "농락 1.",
actorAction = '"안 만져주면 이제 아쉽죠? 몸이 이렇게 솔직하게 달아올랐는데♥" 귓불을 잘근잘근 핥으며 엉덩이에 자지를 끈질기게 비빈다.',
},
{
id = "compliance_one",
label = "완전 조교",
description = "순응 토큰 1개 추가.",
actorAction = '"그냥 다 포기하고 느껴봐요... 자, 기분 좋게 해줄게요♥" 허리를 감싸 쥐고 자신의 발기한 가랑이에 골반을 빈틈없이 맞물려 완전히 길들인다.',
},
},
resolve = function(context) if context.effectChoiceId == "compliance_one" then return { addMood("compliance", 1, "cardChoice") } end return { manipulate(context, 1, "cardChoice") } end,
})
add({
id = "pc_r12_plan_compliance",
name = "유린의 늪",
rarity = "rare",
cardType = "plan",
roles = { "violation" },
cost = 3,
damage = 0,
rules = { "지속 3턴, 충전 2.", "순응 상태의 턴 시작에 저항 피해 6." },
description = "순응하여 저항할 수 없게 된 몸을 품에 가두고, 턴이 시작될 때마다 자비 없는 쾌락을 쏟아붓는 극악의 ::tag[plan]:: 카드입니다.",
placedAction = '"이제 당신은 내 손길 없이는 버틸 수 없게 될 거예요...♥" 허리를 깊숙이 끌어안고 단단해진 자지를 엉덩이골에 깊게 새겨넣듯 밀착한다.',
triggeredAction = "순응해 힘없이 기대어오는 몸을 품에 가둔 채, 옷 너머로 가슴과 가랑이를 동시에 집요하게 감싸 쥐어 주무르며 끊임없는 절정의 지옥으로 몰아넣는다.",
plan = { event = "turn_start", durationTurns = 3, charges = 2, trigger = function(context, event) return event.type == "turn_start" and context.mood == "compliance" end, resolve = function() return { damage(6, "plan") } end },
})
PLAYER · 교전 수치은폐 · {{dict_element::{{tempvar::battle_view_player}}::stealth}}발각되지 않고 행동할 수 있는 여유입니다. 카드 비용과 효과로 감소합니다.상대의 저항이 남은 채 0 이하가 되면 패배하며, 회복 효과로 증가할 수 있습니다.어디든 누르면 닫힙니다
상대의 계획
{{#each {{dict_element::{{tempvar::battle_view_character}}::plans}} plan}}
{{#if {{equal::{{dict_element::{{slot::plan}}::status}}::empty}}}}빈 슬롯{{/if}}
{{#if {{equal::{{dict_element::{{slot::plan}}::status}}::hidden}}}}
? 계획
상대의 계획아직 공개되지 않음상대가 계획을 실행하거나 공개 효과가 적용되기 전까지 내용을 알 수 없습니다.어디든 누르면 닫힙니다
PLAYER · 내 계획{{dict_element::{{dict_element::{{slot::playerPlan}}::card}}::name}}{{#each {{dict_element::{{dict_element::{{slot::playerPlan}}::card}}::descriptionSegments}} playerPlanDescriptionSegment}}{{#if {{equal::{{dict_element::{{slot::playerPlanDescriptionSegment}}::kind}}::text}}}}{{dict_element::{{slot::playerPlanDescriptionSegment}}::value}}{{/if}}{{#if {{equal::{{dict_element::{{slot::playerPlanDescriptionSegment}}::kind}}::tag}}}}{{dict_element::{{slot::playerPlanDescriptionSegment}}::label}}{{/if}}{{/each}}{{#if {{equal::{{dict_element::{{slot::playerPlan}}::hasDuration}}::true}}}}남은 지속시간 · {{dict_element::{{slot::playerPlan}}::remainingTurns}}턴{{/if}}{{#each {{dict_element::{{dict_element::{{slot::playerPlan}}::card}}::ruleLines}} playerPlanRule}}{{#each {{dict_element::{{slot::playerPlanRule}}::segments}} playerPlanRuleSegment}}{{#if {{equal::{{dict_element::{{slot::playerPlanRuleSegment}}::kind}}::text}}}}{{dict_element::{{slot::playerPlanRuleSegment}}::value}}{{/if}}{{#if {{equal::{{dict_element::{{slot::playerPlanRuleSegment}}::kind}}::tag}}}}{{dict_element::{{slot::playerPlanRuleSegment}}::label}}{{/if}}{{/each}}{{/each}}어디든 누르면 닫힙니다
{{/if}}
{{/each}}
무드
현재 · {{dict_element::{{dict_element::{{tempvar::battle_view_character}}::mood}}::label}}
거절
MOOD거절 · {{dict_element::{{dict_element::{{dict_element::{{tempvar::battle_view_character}}::mood}}::tokens}}::rejection}}현재 거절 무드 토큰 수입니다.화면의 게이지는 현재 값을 최대 5칸까지 표시합니다.어디든 누르면 닫힙니다
의심
MOOD의심 · {{dict_element::{{dict_element::{{dict_element::{{tempvar::battle_view_character}}::mood}}::tokens}}::suspicion}}현재 의심 무드 토큰 수입니다.화면의 게이지는 현재 값을 최대 5칸까지 표시합니다.어디든 누르면 닫힙니다
무시
MOOD무시 · {{dict_element::{{dict_element::{{dict_element::{{tempvar::battle_view_character}}::mood}}::tokens}}::ignore}}현재 무시 무드 토큰 수입니다.화면의 게이지는 현재 값을 최대 5칸까지 표시합니다.어디든 누르면 닫힙니다
혼란
MOOD혼란 · {{dict_element::{{dict_element::{{dict_element::{{tempvar::battle_view_character}}::mood}}::tokens}}::confusion}}현재 혼란 무드 토큰 수입니다.화면의 게이지는 현재 값을 최대 5칸까지 표시합니다.어디든 누르면 닫힙니다
순응
MOOD순응 · {{dict_element::{{dict_element::{{dict_element::{{tempvar::battle_view_character}}::mood}}::tokens}}::compliance}}현재 순응 무드 토큰 수입니다.화면의 게이지는 현재 값을 최대 5칸까지 표시합니다.어디든 누르면 닫힙니다
{{#each::keep {{dictelement::{{tempvar::setup_draft_offer}}::cards}} as setup_offer_card}}
{{settempvar::setup_offer_type::{{dictelement::{{slot::setup_offer_card}}::cardType}}}}
보유 {{dictelement::{{slot::setup_offer_card}}::ownedCopies}} / 2{{#when::keep::{{dictelement::{{slot::setup_offer_card}}::rarity}}::is::rare}}희귀{{/when}}
{{dictelement::{{slot::setup_offer_card}}::name}}
{{dictelement::{{tempvar::setup_offer_type}}::label}}{{#each::keep {{dictelement::{{slot::setup_offer_card}}::roles}} as setup_offer_role}}{{dictelement::{{slot::setup_offer_role}}::label}}{{/each}}{{#each::keep {{dictelement::{{slot::setup_offer_card}}::mechanisms}} as setup_offer_mechanism}}{{dictelement::{{slot::setup_offer_mechanism}}::label}}{{/each}}
{{dictelement::{{slot::setup_offer_card}}::baseStealthCost}}비용{{dictelement::{{slot::setup_offer_card}}::baseResistanceDamage}}피해
{{#each::keep {{dictelement::{{slot::setup_offer_card}}::descriptionSegments}} as setup_description_segment}}{{#when::keep::{{dictelement::{{slot::setup_description_segment}}::kind}}::is::text}}{{dictelement::{{slot::setup_description_segment}}::value}}{{/when}}{{#when::keep::{{dictelement::{{slot::setup_description_segment}}::kind}}::isnot::text}}{{#when::keep::{{dictelement::{{slot::setup_description_segment}}::tagKind}}::is::mechanism}}{{dictelement::{{slot::setup_description_segment}}::label}}{{/when}}{{#when::keep::{{dictelement::{{slot::setup_description_segment}}::tagKind}}::isnot::mechanism}}{{dictelement::{{slot::setup_description_segment}}::label}}{{/when}}{{/when}}{{/each}}{{#each::keep {{dictelement::{{slot::setup_offer_card}}::ruleLines}} as setup_rule_line}}{{#each::keep {{dictelement::{{slot::setup_rule_line}}::segments}} as setup_rule_segment}}{{#when::keep::{{dictelement::{{slot::setup_rule_segment}}::kind}}::is::text}}{{dictelement::{{slot::setup_rule_segment}}::value}}{{/when}}{{#when::keep::{{dictelement::{{slot::setup_rule_segment}}::kind}}::isnot::text}}{{#when::keep::{{dictelement::{{slot::setup_rule_segment}}::tagKind}}::is::mechanism}}{{dictelement::{{slot::setup_rule_segment}}::label}}{{/when}}{{#when::keep::{{dictelement::{{slot::setup_rule_segment}}::tagKind}}::isnot::mechanism}}{{dictelement::{{slot::setup_rule_segment}}::label}}{{/when}}{{/when}}{{/each}}{{/each}}카드를 눌러 상세 보기선택 대기
이 카드를 초기 덱에 추가
CARD TYPE{{dictelement::{{tempvar::setup_offer_type}}::label}}{{dictelement::{{tempvar::setup_offer_type}}::tooltip}}어디든 누르면 닫힙니다
{{#each::keep {{dictelement::{{slot::setup_offer_card}}::roles}} as setup_popover_role}}
ROLE{{dictelement::{{slot::setup_popover_role}}::label}}{{dictelement::{{slot::setup_popover_role}}::tooltip}}어디든 누르면 닫힙니다
{{/each}}
{{#each::keep {{dictelement::{{slot::setup_offer_card}}::mechanisms}} as setup_popover_mechanism}}
MECHANISM{{dictelement::{{slot::setup_popover_mechanism}}::label}}{{dictelement::{{slot::setup_popover_mechanism}}::tooltip}}어디든 누르면 닫힙니다
{{/each}}
{{#each::keep {{dictelement::{{slot::setup_offer_card}}::terms}} as setup_popover_term}}
RULE TERM{{dictelement::{{slot::setup_popover_term}}::label}}{{dictelement::{{slot::setup_popover_term}}::tooltip}}어디든 누르면 닫힙니다
{{#each::keep {{dictelement::{{tempvar::setup_draft_deck}}::items}} as setup_deck_item}}
{{dictelement::{{slot::setup_deck_item}}::name}}×{{dictelement::{{slot::setup_deck_item}}::copies}}
{{/each}}
{{#when::keep::{{dictelement::{{tempvar::setup_draft_deck}}::count}}::is::0}}
아직 선택한 카드가 없습니다.
{{/when}}
상세 전투 로그 펼쳐보기 · {{dictelement::{{tempvar::battle_log_view}}::turnCount}}턴
{{#each::keep {{dictelement::{{tempvar::battle_log_view}}::entries}} as battle_log_entry}}
{{#each::keep {{dictelement::{{tempvar::run_progression_reward}}::cards}} as run_reward_card}}
{{settempvar::run_reward_type::{{dictelement::{{slot::run_reward_card}}::cardType}}}}
{{dictelement::{{slot::run_reward_card}}::slot}}보유 {{dictelement::{{slot::run_reward_card}}::ownedCopies}} / 2{{dictelement::{{slot::run_reward_card}}::name}}
{{dictelement::{{tempvar::run_reward_type}}::label}}
{{#each::keep {{dictelement::{{slot::run_reward_card}}::roles}} as run_reward_role}}
{{dictelement::{{slot::run_reward_role}}::label}}
{{/each}}
{{#each::keep {{dictelement::{{slot::run_reward_card}}::mechanisms}} as run_reward_mechanism}}
{{dictelement::{{slot::run_reward_mechanism}}::label}}
{{/each}}
{{#each::keep {{dictelement::{{slot::run_reward_card}}::descriptionSegments}} as run_reward_segment}}{{#when::keep::{{dictelement::{{slot::run_reward_segment}}::kind}}::is::text}}{{dictelement::{{slot::run_reward_segment}}::value}}{{/when}}{{#when::keep::{{dictelement::{{slot::run_reward_segment}}::kind}}::isnot::text}}{{#when::keep::{{dictelement::{{slot::run_reward_segment}}::tagKind}}::is::mechanism}}{{dictelement::{{slot::run_reward_segment}}::label}}{{/when}}{{#when::keep::{{dictelement::{{slot::run_reward_segment}}::tagKind}}::isnot::mechanism}}{{dictelement::{{slot::run_reward_segment}}::label}}{{/when}}{{/when}}{{/each}}
{{#each::keep {{dictelement::{{slot::run_reward_card}}::ruleLines}} as run_reward_rule}}
{{#each::keep {{dictelement::{{slot::run_reward_rule}}::segments}} as run_reward_rule_segment}}{{#when::keep::{{dictelement::{{slot::run_reward_rule_segment}}::kind}}::is::text}}{{dictelement::{{slot::run_reward_rule_segment}}::value}}{{/when}}{{#when::keep::{{dictelement::{{slot::run_reward_rule_segment}}::kind}}::isnot::text}}{{#when::keep::{{dictelement::{{slot::run_reward_rule_segment}}::tagKind}}::is::mechanism}}{{dictelement::{{slot::run_reward_rule_segment}}::label}}{{/when}}{{#when::keep::{{dictelement::{{slot::run_reward_rule_segment}}::tagKind}}::isnot::mechanism}}{{dictelement::{{slot::run_reward_rule_segment}}::label}}{{/when}}{{/when}}{{/each}}
{{/each}}
은폐 비용{{dictelement::{{slot::run_reward_card}}::baseStealthCost}}저항 피해{{dictelement::{{slot::run_reward_card}}::baseResistanceDamage}}
이 카드를 획득 · 클릭해 확정
CARD TYPE{{dictelement::{{tempvar::run_reward_type}}::label}}{{dictelement::{{tempvar::run_reward_type}}::tooltip}}어디든 누르면 닫힙니다
{{#each::keep {{dictelement::{{slot::run_reward_card}}::mechanisms}} as run_popover_mechanism}}
MECHANISM{{dictelement::{{slot::run_popover_mechanism}}::label}}{{dictelement::{{slot::run_popover_mechanism}}::tooltip}}어디든 누르면 닫힙니다
{{/each}}
{{#each::keep {{dictelement::{{slot::run_reward_card}}::terms}} as run_popover_term}}
RULE TERM{{dictelement::{{slot::run_popover_term}}::label}}{{dictelement::{{slot::run_popover_term}}::tooltip}}어디든 누르면 닫힙니다
{{#each::keep {{dictelement::{{slot::run_reward_card}}::roles}} as run_popover_role}}
ROLE{{dictelement::{{slot::run_popover_role}}::label}}{{dictelement::{{slot::run_popover_role}}::tooltip}}어디든 누르면 닫힙니다
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local MAX_SAFE_INTEGER = 9007199254740991
local MAX_PLAN_CAPACITY = 16
local DEFAULT_PLAYER_PLAN_CAPACITY = 1
local PLAYER_CARD_IDS = {
"pc_c01_standard",
"pc_c03_heavy_damage",
"pc_c19_recovery_mood",
"pc_c26_choice_control",
"pc_c28_chain_standard",
"pc_c33_plan_recovery",
}
local CHARACTER_ID = "yoo_jiyoung"
local CHARACTER_CARD_IDS = {
"jiyoung_close_collar",
"jiyoung_quiet_warning",
"jiyoung_turn_to_corner",
"jiyoung_silent_glare",
"jiyoung_timid_call_for_help",
"jiyoung_hug_bag_close",
"jiyoung_half_step_back",
"jiyoung_avoid_eye_contact",
"jiyoung_find_courage",
"jiyoung_phone_in_hand",
}
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
errors = errors,
}
end
local function success(state)
return {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
referencesValidated = true,
state = state,
}
end
local function setupSuccess(state)
return {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
referencesValidated = true,
initialDecksShuffled = true,
state = state,
}
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isSafeInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and math.abs(value) <= MAX_SAFE_INTEGER
and (minimum == nil or value >= minimum)
end
local function isPlanCapacity(value)
return isSafeInteger(value, 1) and value <= MAX_PLAN_CAPACITY
end
local function isRuntimeId(value)
return type(value) == "string"
and string.match(value, "^[A-Za-z0-9][A-Za-z0-9_-]*$") ~= nil
end
local function isAsciiId(value)
return type(value) == "string"
and string.match(value, "^[a-z][a-z0-9_]*$") ~= nil
end
local function getArrayLength(value)
if type(value) ~= "table" or getmetatable(value) ~= nil then
return nil
end
local count = 0
local maximum = 0
for key in pairs(value) do
if type(key) ~= "number" or key % 1 ~= 0 or key < 1 then
return nil
end
count = count + 1
maximum = math.max(maximum, key)
end
if count ~= maximum then
return nil
end
return maximum
end
local function copyArray(value)
local copy = {}
for index = 1, #value do
copy[index] = value[index]
end
return copy
end
local function effectiveCharacterPlanCapacity(characterId, battle, staticData)
local capacityPath = "$.staticData.characters." .. characterId .. ".battle.planCapacity"
if not isPlanCapacity(type(battle) == "table" and battle.planCapacity or nil) then
return nil, makeError(
"invalid_plan_capacity",
capacityPath,
"캐릭터 기본 계획 용량은 1 이상 16 이하의 정수여야 합니다."
)
end
if getArrayLength(battle.traitIds) == nil then
return nil, makeError(
"invalid_character_traits",
"$.staticData.characters." .. characterId .. ".battle.traitIds",
"캐릭터 특징 ID 목록이 연속 배열이 아닙니다."
)
end
local traits = type(staticData) == "table" and staticData.traits or nil
if type(traits) ~= "table" or getmetatable(traits) ~= nil then
return nil, makeError(
"invalid_static_traits",
"$.staticData.traits",
"계획 용량 특징 보정을 계산할 전체 특징 컬렉션이 필요합니다."
)
end
local capacity = battle.planCapacity
for traitIndex, traitId in ipairs(battle.traitIds) do
local trait = traits[traitId]
if type(trait) ~= "table" then
return nil, makeError(
"unknown_character_trait",
"$.staticData.characters." .. characterId .. ".battle.traitIds[" .. traitIndex .. "]",
"계획 용량을 계산할 캐릭터 특징을 찾을 수 없습니다."
)
end
if trait.modifiers ~= nil then
local modifierCount = getArrayLength(trait.modifiers)
if modifierCount == nil then
return nil, makeError(
"invalid_trait_modifiers",
"$.staticData.traits." .. tostring(traitId) .. ".modifiers",
"특징 보정은 연속 배열이어야 합니다."
)
end
for modifierIndex = 1, modifierCount do
local modifier = trait.modifiers[modifierIndex]
local modifierPath = "$.staticData.traits." .. tostring(traitId)
.. ".modifiers[" .. modifierIndex .. "]"
if type(modifier) ~= "table" or getmetatable(modifier) ~= nil then
return nil, makeError(
"invalid_trait_plan_capacity_modifier",
modifierPath,
"계획 용량 특징 보정은 메타테이블 없는 객체여야 합니다."
)
end
for field in pairs(modifier) do
if field ~= "stat" and field ~= "operation" and field ~= "amount" then
return nil, makeError(
"unknown_trait_modifier_field",
modifierPath .. "." .. tostring(field),
"계획 용량 특징 보정에 허용되지 않은 필드가 있습니다."
)
end
end
if modifier.stat ~= "planCapacity"
or modifier.operation ~= "add"
or not isSafeInteger(modifier.amount) then
return nil, makeError(
"invalid_trait_plan_capacity_modifier",
modifierPath,
"계획 용량 특징 보정은 planCapacity/add/정수 amount 형식이어야 합니다."
)
end
capacity = capacity + modifier.amount
if not isSafeInteger(capacity) then
return nil, makeError(
"invalid_effective_plan_capacity",
capacityPath,
"계획 용량 특징 보정 합계가 안전한 정수 범위를 벗어났습니다."
)
end
end
end
end
if not isPlanCapacity(capacity) then
return nil, makeError(
"invalid_effective_plan_capacity",
capacityPath,
"캐릭터 기본 계획 용량과 특징 보정의 합은 1 이상 16 이하여야 합니다."
)
end
return capacity, nil
end
local function copyErrors(report)
local errors = {}
if type(report) ~= "table" or type(report.errors) ~= "table" then
return errors
end
for _, item in ipairs(report.errors) do
if type(item) == "table" then
errors[#errors + 1] = makeError(
type(item.code) == "string" and item.code or "nested_error",
type(item.path) == "string" and item.path or "$",
type(item.message) == "string" and item.message or "하위 상태 생성 작업이 실패했습니다."
)
end
end
return errors
end
local function validateSpec(spec)
local errors = {}
if type(spec) ~= "table" or getmetatable(spec) ~= nil then
return nil, {
makeError("invalid_spec", "$", "verticalSlice 생성 사양은 메타테이블 없는 객체여야 합니다."),
}
end
local allowed = {
battleId = true,
seed = true,
}
for key in pairs(spec) do
if type(key) ~= "string" or not allowed[key] then
errors[#errors + 1] = makeError(
"unknown_spec_field",
"$." .. tostring(key),
"verticalSlice 생성 사양에 알 수 없는 필드가 있습니다."
)
end
end
if not isRuntimeId(spec.battleId) then
errors[#errors + 1] = makeError(
"invalid_battle_id",
"$.battleId",
"명시적인 runtime battleId가 필요합니다."
)
end
if not isSafeInteger(spec.seed, 0) then
errors[#errors + 1] = makeError(
"invalid_rng_seed",
"$.seed",
"명시적인 비음수 안전 정수 RNG 시드가 필요합니다."
)
end
if #errors > 0 then
return nil, errors
end
return {
battleId = spec.battleId,
seed = spec.seed,
}, nil
end
local function validateSetupSpec(spec)
local errors = {}
if type(spec) ~= "table" or getmetatable(spec) ~= nil then
return nil, {
makeError("invalid_spec", "$", "fromSetup 생성 사양은 메타테이블 없는 객체여야 합니다."),
}
end
local allowed = {
battleId = true,
seed = true,
playerCardIds = true,
characterId = true,
}
for key in pairs(spec) do
if type(key) ~= "string" or not allowed[key] then
errors[#errors + 1] = makeError(
"unknown_spec_field",
"$." .. tostring(key),
"fromSetup 생성 사양에 알 수 없는 필드가 있습니다."
)
end
end
if not isRuntimeId(spec.battleId) then
errors[#errors + 1] = makeError(
"invalid_battle_id",
"$.battleId",
"명시적인 runtime battleId가 필요합니다."
)
end
if not isSafeInteger(spec.seed, 0) then
errors[#errors + 1] = makeError(
"invalid_rng_seed",
"$.seed",
"전투 전용 비음수 안전 정수 RNG 시드가 필요합니다."
)
end
local playerDeckLength = getArrayLength(spec.playerCardIds)
if playerDeckLength == nil
or playerDeckLength < 10
or playerDeckLength > 20 then
errors[#errors + 1] = makeError(
"invalid_player_deck",
"$.playerCardIds",
"플레이어 덱은 10장 이상 20장 이하의 연속 배열이어야 합니다."
)
else
local counts = {}
for index, cardId in ipairs(spec.playerCardIds) do
if not isAsciiId(cardId) then
errors[#errors + 1] = makeError(
"invalid_player_card_id",
"$.playerCardIds[" .. index .. "]",
"플레이어 카드 ID는 lower_snake_case ASCII ID여야 합니다."
)
else
counts[cardId] = (counts[cardId] or 0) + 1
if counts[cardId] > 2 then
errors[#errors + 1] = makeError(
"player_card_copy_limit_exceeded",
"$.playerCardIds[" .. index .. "]",
"플레이어 덱에는 같은 카드를 2장까지만 넣을 수 있습니다."
)
end
end
end
end
if not isAsciiId(spec.characterId) then
errors[#errors + 1] = makeError(
"invalid_character_id",
"$.characterId",
"캐릭터 ID는 lower_snake_case ASCII ID여야 합니다."
)
end
if #errors > 0 then
return nil, errors
end
return {
battleId = spec.battleId,
seed = spec.seed,
playerCardIds = copyArray(spec.playerCardIds),
characterId = spec.characterId,
}, nil
end
local function getCharacterDefinition(staticData)
if type(staticData) ~= "table" or getmetatable(staticData) ~= nil then
return nil, makeError(
"invalid_static_data",
"$.staticData",
"전체 정적 데이터 객체가 필요합니다."
)
end
local characters = staticData.characters
local character = type(characters) == "table" and characters[CHARACTER_ID] or nil
local battle = type(character) == "table" and character.battle or nil
if type(battle) ~= "table" then
return nil, makeError(
"missing_character_definition",
"$.staticData.characters." .. CHARACTER_ID,
"verticalSlice 캐릭터 전투 정의를 찾을 수 없습니다."
)
end
local deckLength = getArrayLength(battle.deck)
if deckLength ~= #CHARACTER_CARD_IDS then
return nil, makeError(
"vertical_slice_character_deck_mismatch",
"$.staticData.characters." .. CHARACTER_ID .. ".battle.deck",
"verticalSlice 캐릭터 덱 구성이 고정 사양과 다릅니다."
)
end
for index, cardId in ipairs(CHARACTER_CARD_IDS) do
if battle.deck[index] ~= cardId then
return nil, makeError(
"vertical_slice_character_deck_mismatch",
"$.staticData.characters." .. CHARACTER_ID .. ".battle.deck[" .. index .. "]",
"verticalSlice 캐릭터 덱 순서가 고정 사양과 다릅니다."
)
end
end
if getArrayLength(battle.traitIds) == nil then
return nil, makeError(
"invalid_character_traits",
"$.staticData.characters." .. CHARACTER_ID .. ".battle.traitIds",
"캐릭터 특징 ID 목록이 연속 배열이 아닙니다."
)
end
return character, nil
end
local function makeInstances(cardIds, owner)
local instances = {}
for index, cardId in ipairs(cardIds) do
instances[index] = {
instanceId = string.format("%s-%03d", owner, index),
cardId = cardId,
owner = owner,
zone = "deck",
position = index,
}
end
return instances
end
local function getSetupDefinitions(spec, staticData)
local errors = {}
if type(staticData) ~= "table" or getmetatable(staticData) ~= nil then
return nil, nil, errors, makeError(
"invalid_static_data",
"$.staticData",
"전체 정적 데이터 객체가 필요합니다."
)
end
local cards = staticData.cards
if type(cards) ~= "table" or getmetatable(cards) ~= nil then
return nil, nil, errors, makeError(
"invalid_static_cards",
"$.staticData.cards",
"정적 카드 컬렉션이 필요합니다."
)
end
for index, cardId in ipairs(spec.playerCardIds) do
local card = cards[cardId]
if type(card) ~= "table" then
errors[#errors + 1] = makeError(
"unknown_player_card",
"$.playerCardIds[" .. index .. "]",
"정적 DB에서 플레이어 카드를 찾을 수 없습니다."
)
elseif card.owner ~= "player" then
errors[#errors + 1] = makeError(
"player_card_owner_mismatch",
"$.playerCardIds[" .. index .. "]",
"선택한 카드의 정적 소유자가 player가 아닙니다."
)
end
end
local characters = staticData.characters
local character = type(characters) == "table" and characters[spec.characterId] or nil
local battle = type(character) == "table" and character.battle or nil
if type(battle) ~= "table" or getmetatable(battle) ~= nil then
return nil, nil, errors, makeError(
"missing_character_definition",
"$.staticData.characters." .. spec.characterId,
"선택한 캐릭터의 전투 정의를 찾을 수 없습니다."
)
end
local characterDeckLength = getArrayLength(battle.deck)
if characterDeckLength == nil then
errors[#errors + 1] = makeError(
"invalid_character_deck",
"$.staticData.characters." .. spec.characterId .. ".battle.deck",
"캐릭터 덱은 연속 배열이어야 합니다."
)
else
for index, cardId in ipairs(battle.deck) do
local path = "$.staticData.characters." .. spec.characterId .. ".battle.deck[" .. index .. "]"
if not isAsciiId(cardId) then
errors[#errors + 1] = makeError(
"invalid_character_card_id",
path,
"캐릭터 카드 ID는 lower_snake_case ASCII ID여야 합니다."
)
else
local card = cards[cardId]
if type(card) ~= "table" then
errors[#errors + 1] = makeError(
"unknown_character_card",
path,
"정적 DB에서 캐릭터 카드를 찾을 수 없습니다."
)
elseif card.owner ~= "character" then
errors[#errors + 1] = makeError(
"character_card_owner_mismatch",
path,
"캐릭터 덱 카드의 정적 소유자가 character가 아닙니다."
)
end
end
end
end
if getArrayLength(battle.traitIds) == nil then
errors[#errors + 1] = makeError(
"invalid_character_traits",
"$.staticData.characters." .. spec.characterId .. ".battle.traitIds",
"캐릭터 특징 ID 목록이 연속 배열이 아닙니다."
)
end
return character, battle, errors, nil
end
local function buildJourney(seed, staticData, turnLimit)
if type(runScript) ~= "function" then
return nil, {
makeError(
"runtime_unavailable",
"$.runtime.subwayJourney",
"전투 여정을 생성할 실행기를 찾을 수 없습니다."
),
}
end
local ok, report = pcall(
runScript,
triggerId,
"subwayJourney",
"build",
seed,
staticData,
turnLimit
)
if not ok then
return nil, {
makeError(
"subway_journey_call_failed",
"$.runtime.subwayJourney",
"전투 여정 생성 호출에 실패했습니다: " .. tostring(report)
),
}
end
if type(report) ~= "table" or report.ok ~= true
or not isSafeInteger(report.turnLimit, 1)
or report.turnLimit ~= turnLimit
or type(report.transit) ~= "table"
or type(report.sceneContext) ~= "table" then
local nestedErrors = copyErrors(report)
if #nestedErrors == 0 then
nestedErrors[1] = makeError(
"invalid_subway_journey_result",
"$.runtime.subwayJourney",
"전투 여정 생성기가 제한 턴과 이동 구간을 반환하지 않았습니다."
)
end
return nil, nestedErrors
end
return {
turnLimit = report.turnLimit,
transit = report.transit,
sceneContext = report.sceneContext,
}, nil
end
local function verticalSlice(spec, staticData)
local normalized, specErrors = validateSpec(spec)
if specErrors then
return failure(specErrors)
end
local characterDefinition, characterError = getCharacterDefinition(staticData)
if characterError then
return failure({ characterError })
end
local characterBattle = characterDefinition.battle
local characterPlanCapacity, planCapacityError = effectiveCharacterPlanCapacity(
CHARACTER_ID,
characterBattle,
staticData
)
if planCapacityError then
return failure({ planCapacityError })
end
local journey, journeyErrors = buildJourney(normalized.seed, staticData, characterBattle.turnLimit)
if journeyErrors then
return failure(journeyErrors)
end
local cardInstances = makeInstances(PLAYER_CARD_IDS, "player")
local characterInstances = makeInstances(CHARACTER_CARD_IDS, "character")
for _, instance in ipairs(characterInstances) do
cardInstances[#cardInstances + 1] = instance
end
local stateSpec = {
battleId = normalized.battleId,
status = "active",
turnNumber = 1,
turnLimit = journey.turnLimit,
transit = journey.transit,
sceneContext = journey.sceneContext,
rng = {
seed = normalized.seed,
cursor = 0,
},
player = {
stealth = 30,
baseDrawCount = 3,
maxHandSize = 5,
perkIds = {},
planCapacity = DEFAULT_PLAYER_PLAN_CAPACITY,
planSlots = {},
},
character = {
characterId = CHARACTER_ID,
resistance = characterBattle.startingResistance,
mood = characterBattle.startingMood,
traitIds = copyArray(characterBattle.traitIds),
baseDrawCount = characterBattle.baseDrawCount,
maxHandSize = characterBattle.maxHandSize,
planCapacity = characterPlanCapacity,
planSlots = {},
},
cardInstances = cardInstances,
selection = {
playerCardInstanceIds = {},
},
characterIntent = {
cardInstanceIds = {},
},
}
if type(runScript) ~= "function" then
return failure({
makeError(
"runtime_unavailable",
"$.runtime.stateSchema",
"stateSchema 실행기를 찾을 수 없습니다."
),
})
end
local ok, report = pcall(
runScript,
triggerId,
"stateSchema",
"newBattleState",
stateSpec,
staticData
)
if not ok then
return failure({
makeError(
"state_schema_call_error",
"$.runtime.stateSchema",
"stateSchema.newBattleState 호출 중 오류가 발생했습니다."
),
})
end
if type(report) ~= "table" then
return failure({
makeError(
"invalid_state_schema_result",
"$.runtime.stateSchema",
"stateSchema.newBattleState가 테이블 결과를 반환하지 않았습니다."
),
})
end
if report.ok ~= true then
local nestedErrors = copyErrors(report)
if #nestedErrors == 0 then
nestedErrors[1] = makeError(
"state_construction_failed",
"$",
"stateSchema.newBattleState가 오류 상세 없이 실패했습니다."
)
end
return failure(nestedErrors)
end
if report.referencesValidated ~= true then
return failure({
makeError(
"static_references_not_validated",
"$.staticData",
"verticalSlice battleState는 전체 정적 참조 검증이 필요합니다."
),
})
end
if type(report.value) ~= "table" then
return failure({
makeError(
"missing_battle_state",
"$.state",
"stateSchema.newBattleState 성공 결과에 battleState가 없습니다."
),
})
end
return success(report.value)
end
local function fromSetup(spec, staticData)
local normalized, specErrors = validateSetupSpec(spec)
if specErrors then
return failure(specErrors)
end
local characterDefinition, characterBattle, definitionErrors, definitionFatal =
getSetupDefinitions(normalized, staticData)
if definitionFatal then
definitionErrors[#definitionErrors + 1] = definitionFatal
end
if #definitionErrors > 0 then
return failure(definitionErrors)
end
local characterPlanCapacity, planCapacityError = effectiveCharacterPlanCapacity(
normalized.characterId,
characterBattle,
staticData
)
if planCapacityError then
return failure({ planCapacityError })
end
local journey, journeyErrors = buildJourney(normalized.seed, staticData, characterBattle.turnLimit)
if journeyErrors then
return failure(journeyErrors)
end
local cardInstances = makeInstances(normalized.playerCardIds, "player")
local characterInstances = makeInstances(characterBattle.deck, "character")
for _, instance in ipairs(characterInstances) do
cardInstances[#cardInstances + 1] = instance
end
local stateSpec = {
battleId = normalized.battleId,
status = "active",
turnNumber = 1,
turnLimit = journey.turnLimit,
transit = journey.transit,
sceneContext = journey.sceneContext,
rng = {
seed = normalized.seed,
cursor = 0,
},
player = {
stealth = 30,
baseDrawCount = 3,
maxHandSize = 5,
perkIds = {},
planCapacity = DEFAULT_PLAYER_PLAN_CAPACITY,
planSlots = {},
},
character = {
characterId = normalized.characterId,
resistance = characterBattle.startingResistance,
mood = characterBattle.startingMood,
traitIds = copyArray(characterBattle.traitIds),
baseDrawCount = characterBattle.baseDrawCount,
maxHandSize = characterBattle.maxHandSize,
planCapacity = characterPlanCapacity,
planSlots = {},
},
cardInstances = cardInstances,
selection = {
playerCardInstanceIds = {},
},
characterIntent = {
cardInstanceIds = {},
},
}
if type(runScript) ~= "function" then
return failure({
makeError(
"runtime_unavailable",
"$.runtime.stateSchema",
"전투 초기 상태 생성에 필요한 실행기를 찾을 수 없습니다."
),
})
end
local constructOk, constructReport = pcall(
runScript,
triggerId,
"stateSchema",
"newBattleState",
stateSpec,
staticData
)
if not constructOk then
return failure({
makeError(
"state_schema_call_error",
"$.runtime.stateSchema",
"stateSchema.newBattleState 호출 중 오류가 발생했습니다."
),
})
end
if type(constructReport) ~= "table" then
return failure({
makeError(
"invalid_state_schema_result",
"$.runtime.stateSchema",
"stateSchema.newBattleState가 테이블 결과를 반환하지 않았습니다."
),
})
end
if constructReport.ok ~= true then
local nestedErrors = copyErrors(constructReport)
if #nestedErrors == 0 then
nestedErrors[1] = makeError(
"state_construction_failed",
"$",
"stateSchema.newBattleState가 오류 상세 없이 실패했습니다."
)
end
return failure(nestedErrors)
end
if constructReport.referencesValidated ~= true then
return failure({
makeError(
"static_references_not_validated",
"$.staticData",
"fromSetup battleState는 전체 정적 참조 검증이 필요합니다."
),
})
end
if type(constructReport.value) ~= "table" then
return failure({
makeError(
"missing_battle_state",
"$.state",
"stateSchema.newBattleState 성공 결과에 battleState가 없습니다."
),
})
end
local playerShuffleOk, playerShuffleReport = pcall(
runScript,
triggerId,
"cardZones",
"shuffleDeck",
constructReport.value,
"player"
)
if not playerShuffleOk then
return failure({
makeError(
"player_shuffle_call_error",
"$.runtime.cardZones",
"플레이어 초기 덱 셔플 호출 중 오류가 발생했습니다."
),
})
end
if type(playerShuffleReport) ~= "table" then
return failure({
makeError(
"invalid_player_shuffle_result",
"$.runtime.cardZones",
"플레이어 초기 덱 셔플이 테이블 결과를 반환하지 않았습니다."
),
})
end
if playerShuffleReport.ok ~= true then
local nestedErrors = copyErrors(playerShuffleReport)
if #nestedErrors == 0 then
nestedErrors[1] = makeError(
"player_shuffle_failed",
"$.state.player",
"플레이어 초기 덱 셔플이 오류 상세 없이 실패했습니다."
)
end
return failure(nestedErrors)
end
if type(playerShuffleReport.state) ~= "table" then
return failure({
makeError(
"missing_player_shuffle_state",
"$.state",
"플레이어 초기 덱 셔플 성공 결과에 battleState가 없습니다."
),
})
end
local characterShuffleOk, characterShuffleReport = pcall(
runScript,
triggerId,
"cardZones",
"shuffleDeck",
playerShuffleReport.state,
"character"
)
if not characterShuffleOk then
return failure({
makeError(
"character_shuffle_call_error",
"$.runtime.cardZones",
"캐릭터 초기 덱 셔플 호출 중 오류가 발생했습니다."
),
})
end
if type(characterShuffleReport) ~= "table" then
return failure({
makeError(
"invalid_character_shuffle_result",
"$.runtime.cardZones",
"캐릭터 초기 덱 셔플이 테이블 결과를 반환하지 않았습니다."
),
})
end
if characterShuffleReport.ok ~= true then
local nestedErrors = copyErrors(characterShuffleReport)
if #nestedErrors == 0 then
nestedErrors[1] = makeError(
"character_shuffle_failed",
"$.state.character",
"캐릭터 초기 덱 셔플이 오류 상세 없이 실패했습니다."
)
end
return failure(nestedErrors)
end
if type(characterShuffleReport.state) ~= "table" then
return failure({
makeError(
"missing_character_shuffle_state",
"$.state",
"캐릭터 초기 덱 셔플 성공 결과에 battleState가 없습니다."
),
})
end
local validateOk, validateReport = pcall(
runScript,
triggerId,
"stateSchema",
"validateBattleState",
characterShuffleReport.state,
staticData
)
if not validateOk then
return failure({
makeError(
"state_validation_call_error",
"$.runtime.stateSchema",
"셔플된 battleState 최종 검증 호출 중 오류가 발생했습니다."
),
})
end
if type(validateReport) ~= "table" then
return failure({
makeError(
"invalid_state_validation_result",
"$.runtime.stateSchema",
"stateSchema.validateBattleState가 테이블 결과를 반환하지 않았습니다."
),
})
end
if validateReport.ok ~= true then
local nestedErrors = copyErrors(validateReport)
if #nestedErrors == 0 then
nestedErrors[1] = makeError(
"state_validation_failed",
"$.state",
"셔플된 battleState 최종 검증이 오류 상세 없이 실패했습니다."
)
end
return failure(nestedErrors)
end
if validateReport.referencesValidated ~= true then
return failure({
makeError(
"static_references_not_validated",
"$.staticData",
"셔플된 fromSetup battleState는 전체 정적 참조 검증이 필요합니다."
),
})
end
return setupSuccess(characterShuffleReport.state)
end
local arguments = { ... }
if action == "verticalSlice" then
return verticalSlice(arguments[1], arguments[2])
elseif action == "fromSetup" then
return fromSetup(arguments[1], arguments[2])
end
return failure({
makeError(
"unknown_action",
"$.action",
"지원하지 않는 battleBootstrap 작업입니다: " .. tostring(action)
),
})
end)
#29battleController.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local ACTIVE_REQUEST_SCHEMA_VERSION = 3
local AFTERMATH_SCHEMA_VERSION = 2
local VIEW_NAME = "battleView"
local INTERACTION_VIEW_NAME = "battleInteractionView"
local BATTLE_LOG_VIEW_NAME = "battleLogView"
local UI_BODY_NAME = "🔯🔯🔯"
local UI_INTERACTION_NAME = "helltrainBattleInteractionV1"
local UI_INTERACTION_MARKER = ""
local TURN_SUBMIT_MARKER = "@@HELLTRAIN_TURN_SUBMIT_V1@@"
local SAY_NOTHING = "says nothing"
local CHAT_FINGERPRINT_ALGORITHM = "canonical_poly131_137_chat_v1"
local KEYS = {
authority = "battleRuntimeV1.authority",
draft = "battleRuntimeV1.draft",
pending = "battleRuntimeV1.pending",
lastCommittedPending = "battleRuntimeV1.lastCommittedPending",
activeRequest = "battleRuntimeV1.activeRequest",
submission = "battleRuntimeV1.submission",
aftermath = "battleRuntimeV1.aftermath",
}
local function permitCanonicalBattleView(purpose, viewName)
return purpose == "dataBridgeCanonicalV1"
and (viewName == VIEW_NAME
or viewName == INTERACTION_VIEW_NAME
or viewName == BATTLE_LOG_VIEW_NAME)
end
local function allowsCanonicalHandoff(capability, purpose)
if type(capability) ~= "function" then return false end
local ok, allowed = pcall(capability, purpose)
return ok and allowed == true
end
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
errors = errors,
}
end
local function success(fields)
local report = {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
}
for key, value in pairs(fields or {}) do
report[key] = value
end
return report
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and (minimum == nil or value >= minimum)
end
local function isRuntimeId(value)
return type(value) == "string"
and string.match(value, "^[A-Za-z0-9][A-Za-z0-9_-]*$") ~= nil
end
local function objectPath(path, key)
if string.match(key, "^[A-Za-z_][A-Za-z0-9_]*$") then
return path .. "." .. key
end
return path .. "[" .. string.format("%q", key) .. "]"
end
local function cloneJson(value, path, active)
local valueType = type(value)
if valueType == "nil" or valueType == "string" or valueType == "boolean" then
return value, nil
end
if valueType == "number" then
if not isFinite(value) then
return nil, makeError("non_finite_number", path, "유한하지 않은 숫자는 컨트롤러 데이터에 사용할 수 없습니다.")
end
return value, nil
end
if valueType ~= "table" then
return nil, makeError(
"unsupported_type",
path,
"JSON 컨트롤러 데이터에 사용할 수 없는 자료형입니다: " .. valueType
)
end
if getmetatable(value) ~= nil then
return nil, makeError("metatable_not_allowed", path, "컨트롤러 데이터에는 메타테이블을 사용할 수 없습니다.")
end
active = active or {}
if active[value] then
return nil, makeError("circular_reference", path, "순환 참조는 컨트롤러 데이터에 사용할 수 없습니다.")
end
active[value] = true
local hasNumeric = false
local hasString = false
local numericCount = 0
local maximum = 0
for key in pairs(value) do
if type(key) == "number" then
hasNumeric = true
if not isInteger(key, 1) then
active[value] = nil
return nil, makeError("invalid_array_index", path, "배열 인덱스는 1 이상의 정수여야 합니다.")
end
numericCount = numericCount + 1
maximum = math.max(maximum, key)
elseif type(key) == "string" then
hasString = true
else
active[value] = nil
return nil, makeError("invalid_object_key", path, "객체 키는 문자열이어야 합니다.")
end
end
if hasNumeric and hasString then
active[value] = nil
return nil, makeError("mixed_table", path, "숫자 인덱스와 문자열 키를 함께 사용할 수 없습니다.")
end
if hasNumeric and numericCount ~= maximum then
active[value] = nil
return nil, makeError("sparse_array", path, "배열 인덱스는 1부터 빈틈없이 이어져야 합니다.")
end
local copy = {}
if hasNumeric then
for index = 1, maximum do
local itemCopy, itemError = cloneJson(value[index], path .. "[" .. index .. "]", active)
if itemError then
active[value] = nil
return nil, itemError
end
copy[index] = itemCopy
end
else
for key, item in pairs(value) do
local itemCopy, itemError = cloneJson(item, objectPath(path, key), active)
if itemError then
active[value] = nil
return nil, itemError
end
copy[key] = itemCopy
end
end
active[value] = nil
return copy, nil
end
local function deepEqual(left, right, active)
if type(left) ~= type(right) then
return false
end
if type(left) ~= "table" then
return left == right
end
if getmetatable(left) ~= nil or getmetatable(right) ~= nil then
return false
end
active = active or {}
local seen = active[left]
if seen ~= nil then
return seen == right
end
active[left] = right
for key, value in pairs(left) do
if not deepEqual(value, right[key], active) then
active[left] = nil
return false
end
end
for key in pairs(right) do
if left[key] == nil then
active[left] = nil
return false
end
end
active[left] = nil
return true
end
local function canonicalFailure(code, path, message)
error(makeError(code, path, message), 0)
end
local function inspectCanonicalTable(value, path)
if type(value) ~= "table" or getmetatable(value) ~= nil then
canonicalFailure("invalid_fingerprint_table", path, "fingerprint 대상은 일반 테이블이어야 합니다.")
end
local numericCount = 0
local maximum = 0
local hasNumeric = false
local hasString = false
local stringKeys = {}
for key in pairs(value) do
if type(key) == "number" then
hasNumeric = true
numericCount = numericCount + 1
if not isInteger(key, 1) then
canonicalFailure("invalid_fingerprint_array_index", path, "fingerprint 배열 인덱스는 1 이상의 정수여야 합니다.")
end
maximum = math.max(maximum, key)
elseif type(key) == "string" then
hasString = true
stringKeys[#stringKeys + 1] = key
else
canonicalFailure("invalid_fingerprint_object_key", path, "fingerprint 객체 키는 문자열이어야 합니다.")
end
end
if hasNumeric and hasString then
canonicalFailure("mixed_fingerprint_table", path, "fingerprint에는 숫자 인덱스와 문자열 키를 함께 사용할 수 없습니다.")
end
if hasNumeric and numericCount ~= maximum then
canonicalFailure("sparse_fingerprint_array", path, "fingerprint 배열 인덱스는 1부터 빈틈없이 이어져야 합니다.")
end
table.sort(stringKeys)
return hasNumeric, maximum, stringKeys
end
local function canonicalJson(value, path, active)
local valueType = type(value)
if valueType == "nil" then return "n" end
if valueType == "boolean" then return value and "t" or "f" end
if valueType == "number" then
if not isFinite(value) then
canonicalFailure("non_finite_fingerprint_number", path, "유한하지 않은 숫자는 fingerprint에 사용할 수 없습니다.")
end
return "d" .. string.format("%.17g", value) .. ";"
end
if valueType == "string" then
return "s" .. tostring(#value) .. ":" .. value
end
if valueType ~= "table" then
canonicalFailure("unsupported_fingerprint_type", path, "fingerprint에 사용할 수 없는 자료형입니다: " .. valueType)
end
active = active or {}
if active[value] then
canonicalFailure("circular_fingerprint_value", path, "순환 참조는 fingerprint에 사용할 수 없습니다.")
end
active[value] = true
local isArray, length, stringKeys = inspectCanonicalTable(value, path)
local parts = {}
if isArray then
parts[1] = "["
for index = 1, length do
parts[#parts + 1] = canonicalJson(value[index], path .. "[" .. index .. "]", active)
end
parts[#parts + 1] = "]"
else
parts[1] = "{"
for _, key in ipairs(stringKeys) do
parts[#parts + 1] = "k" .. tostring(#key) .. ":" .. key
parts[#parts + 1] = canonicalJson(value[key], objectPath(path, key), active)
end
parts[#parts + 1] = "}"
end
active[value] = nil
return table.concat(parts)
end
local function fingerprintChatRange(chat, firstIndex, count, path)
if type(chat) ~= "table" or not isInteger(firstIndex, 1) or not isInteger(count, 0) then
return nil, makeError("invalid_chat_fingerprint_range", path, "채팅 fingerprint 범위가 올바르지 않습니다.")
end
if count > 0 and firstIndex + count - 1 > #chat then
return nil, makeError("chat_fingerprint_range_out_of_bounds", path, "채팅 fingerprint 범위가 대화 길이를 벗어났습니다.")
end
local ok, canonical = pcall(function()
local parts = { "[" }
for offset = 0, count - 1 do
parts[#parts + 1] = canonicalJson(chat[firstIndex + offset], path .. "[" .. offset .. "]", {})
end
parts[#parts + 1] = "]"
return table.concat(parts)
end)
if not ok then
if type(canonical) == "table" and canonical.code and canonical.path and canonical.message then
return nil, canonical
end
return nil, makeError("chat_fingerprint_failed", path, "채팅 fingerprint 생성에 실패했습니다: " .. tostring(canonical))
end
local hashA = 0
local hashB = 0
for index = 1, #canonical do
local byte = string.byte(canonical, index)
hashA = (hashA * 131 + byte) % 2147483647
hashB = (hashB * 137 + byte) % 2147483629
end
return {
algorithm = CHAT_FINGERPRINT_ALGORITHM,
length = #canonical,
hashA = hashA,
hashB = hashB,
}, nil
end
local function fingerprintsEqual(left, right)
return type(left) == "table"
and type(right) == "table"
and left.algorithm == right.algorithm
and left.length == right.length
and left.hashA == right.hashA
and left.hashB == right.hashB
end
local function validateFingerprint(value, path, errors)
if type(value) ~= "table" or getmetatable(value) ~= nil then
errors[#errors + 1] = makeError("invalid_chat_fingerprint", path, "채팅 fingerprint가 일반 객체가 아닙니다.")
return false
end
local allowed = { algorithm = true, length = true, hashA = true, hashB = true }
for key in pairs(value) do
if type(key) ~= "string" or not allowed[key] then
errors[#errors + 1] = makeError("unknown_chat_fingerprint_field", path .. "." .. tostring(key), "채팅 fingerprint에 알 수 없는 필드가 있습니다.")
end
end
if value.algorithm ~= CHAT_FINGERPRINT_ALGORITHM then
errors[#errors + 1] = makeError("invalid_chat_fingerprint_algorithm", path .. ".algorithm", "지원하지 않는 채팅 fingerprint 알고리즘입니다.")
end
for _, field in ipairs({ "length", "hashA", "hashB" }) do
if not isInteger(value[field], 0) then
errors[#errors + 1] = makeError("invalid_chat_fingerprint", path .. "." .. field, "채팅 fingerprint 수치는 0 이상의 정수여야 합니다.")
end
end
return #errors == 0
end
local function validateAftermathOutputObserved(receipt, request, path, errors)
if type(receipt) ~= "table" or getmetatable(receipt) ~= nil then
errors[#errors + 1] = makeError("invalid_aftermath_output_observed", path, "자유행동 출력 관측 영수증이 일반 객체가 아닙니다.")
return
end
local allowed = {
schemaVersion = true,
kind = true,
attemptNumber = true,
responseLuaIndex = true,
responseFingerprint = true,
}
for key in pairs(receipt) do
if type(key) ~= "string" or not allowed[key] then
errors[#errors + 1] = makeError("unknown_aftermath_output_observed_field", path .. "." .. tostring(key), "자유행동 출력 관측 영수증에 알 수 없는 필드가 있습니다.")
end
end
local attemptNumber = type(request) == "table" and (request.attemptNumber or 1) or nil
if receipt.schemaVersion ~= 1 or receipt.kind ~= "battleAftermathOutputObserved" then
errors[#errors + 1] = makeError("invalid_aftermath_output_observed_schema", path, "자유행동 출력 관측 영수증 형식이 올바르지 않습니다.")
end
if receipt.attemptNumber ~= attemptNumber then
errors[#errors + 1] = makeError("aftermath_output_observed_attempt_mismatch", path .. ".attemptNumber", "자유행동 출력 관측 시도가 현재 요청과 다릅니다.")
end
local expectedIndex = type(request) == "table"
and isInteger(request.userLuaIndex, 1)
and request.userLuaIndex + 1
or nil
if not isInteger(receipt.responseLuaIndex, 1) or receipt.responseLuaIndex ~= expectedIndex then
errors[#errors + 1] = makeError("aftermath_output_observed_index_mismatch", path .. ".responseLuaIndex", "자유행동 출력 관측 위치가 현재 요청과 다릅니다.")
end
validateFingerprint(receipt.responseFingerprint, path .. ".responseFingerprint", errors)
end
local function validateAftermathRecoveringCleanup(receipt, request, path, errors)
if type(receipt) ~= "table" or getmetatable(receipt) ~= nil then
errors[#errors + 1] = makeError("invalid_aftermath_recovering_cleanup", path, "자유행동 응답 삭제 복구 영수증이 일반 객체가 아닙니다.")
return
end
local allowed = {
schemaVersion = true,
kind = true,
attemptNumber = true,
responseLuaIndex = true,
responseFingerprint = true,
}
for key in pairs(receipt) do
if type(key) ~= "string" or not allowed[key] then
errors[#errors + 1] = makeError("unknown_aftermath_recovering_cleanup_field", path .. "." .. tostring(key), "자유행동 응답 삭제 복구 영수증에 알 수 없는 필드가 있습니다.")
end
end
if receipt.schemaVersion ~= 1 or receipt.kind ~= "battleAftermathRecoveringCleanup" then
errors[#errors + 1] = makeError("invalid_aftermath_recovering_cleanup_schema", path, "자유행동 응답 삭제 복구 영수증 형식이 올바르지 않습니다.")
end
if receipt.attemptNumber ~= (type(request) == "table" and (request.attemptNumber or 1) or nil) then
errors[#errors + 1] = makeError("aftermath_recovering_cleanup_attempt_mismatch", path .. ".attemptNumber", "자유행동 응답 삭제 복구 시도가 현재 요청과 다릅니다.")
end
local expectedIndex = type(request) == "table"
and isInteger(request.userLuaIndex, 1)
and request.userLuaIndex + 1
or nil
if not isInteger(receipt.responseLuaIndex, 1) or receipt.responseLuaIndex ~= expectedIndex then
errors[#errors + 1] = makeError("aftermath_recovering_cleanup_index_mismatch", path .. ".responseLuaIndex", "자유행동 응답 삭제 복구 위치가 현재 요청과 다릅니다.")
end
validateFingerprint(receipt.responseFingerprint, path .. ".responseFingerprint", errors)
end
local function validateAftermathCommitted(receipt, completedTurnNumber, path, errors)
if type(receipt) ~= "table" or getmetatable(receipt) ~= nil then
errors[#errors + 1] = makeError("invalid_aftermath_committed", path, "확정된 자유행동 영수증이 일반 객체가 아닙니다.")
return
end
local allowed = {
schemaVersion = true,
kind = true,
turnNumber = true,
responseLuaIndex = true,
prefixFingerprint = true,
responseIdentityFingerprint = true,
}
for key in pairs(receipt) do
if type(key) ~= "string" or not allowed[key] then
errors[#errors + 1] = makeError("unknown_aftermath_committed_field", path .. "." .. tostring(key), "확정된 자유행동 영수증에 알 수 없는 필드가 있습니다.")
end
end
if receipt.schemaVersion ~= 1 or receipt.kind ~= "battleAftermathCommitted" then
errors[#errors + 1] = makeError("invalid_aftermath_committed_schema", path, "확정된 자유행동 영수증 형식이 올바르지 않습니다.")
end
if receipt.turnNumber ~= completedTurnNumber then
errors[#errors + 1] = makeError("aftermath_committed_turn_mismatch", path .. ".turnNumber", "확정된 자유행동 영수증의 턴이 현재 진행과 다릅니다.")
end
if not isInteger(receipt.responseLuaIndex, 1) then
errors[#errors + 1] = makeError("invalid_aftermath_committed_index", path .. ".responseLuaIndex", "확정된 자유행동 응답 위치가 올바르지 않습니다.")
end
validateFingerprint(receipt.prefixFingerprint, path .. ".prefixFingerprint", errors)
validateFingerprint(receipt.responseIdentityFingerprint, path .. ".responseIdentityFingerprint", errors)
end
local function validateAftermath(value, authority)
local errors = {}
local path = "$.aftermath"
if type(value) ~= "table" or getmetatable(value) ~= nil then
return {
makeError("invalid_aftermath", path, "승리 후 자유행동 상태가 일반 객체가 아닙니다."),
}
end
local allowed = {
schemaVersion = true,
kind = true,
battleId = true,
victoryTurnNumber = true,
completedTurnNumber = true,
phase = true,
request = true,
lastCommitted = true,
}
for key in pairs(value) do
if type(key) ~= "string" or not allowed[key] then
errors[#errors + 1] = makeError(
"unknown_aftermath_field",
path .. "." .. tostring(key),
"승리 후 자유행동 상태에 알 수 없는 필드가 있습니다."
)
end
end
if (value.schemaVersion ~= 1 and value.schemaVersion ~= AFTERMATH_SCHEMA_VERSION)
or value.kind ~= "battleAftermath" then
errors[#errors + 1] = makeError("invalid_aftermath_schema", path, "승리 후 자유행동 상태 형식이 올바르지 않습니다.")
end
if not isRuntimeId(value.battleId) then
errors[#errors + 1] = makeError("invalid_aftermath_battle", path .. ".battleId", "자유행동 battleId가 올바르지 않습니다.")
end
if not isInteger(value.victoryTurnNumber, 1)
or not isInteger(value.completedTurnNumber, 1)
or value.completedTurnNumber < value.victoryTurnNumber then
errors[#errors + 1] = makeError("invalid_aftermath_turn", path, "자유행동 턴 범위가 올바르지 않습니다.")
end
if value.phase ~= "ready"
and value.phase ~= "inFlight"
and value.phase ~= "requestInjected"
and value.phase ~= "settling"
and value.phase ~= "complete" then
errors[#errors + 1] = makeError("invalid_aftermath_phase", path .. ".phase", "자유행동 phase가 올바르지 않습니다.")
end
if type(authority) ~= "table"
or authority.status ~= "victory"
or authority.battleId ~= value.battleId
or authority.turnNumber ~= value.victoryTurnNumber
or not isInteger(authority.turnLimit, 1) then
errors[#errors + 1] = makeError("aftermath_authority_mismatch", path, "자유행동 상태가 조기 승리 전투와 일치하지 않습니다.")
elseif value.phase == "complete" then
if value.completedTurnNumber ~= authority.turnLimit then
errors[#errors + 1] = makeError("incomplete_aftermath", path .. ".completedTurnNumber", "완료된 자유행동이 도착 턴에 이르지 않았습니다.")
end
elseif value.phase == "settling" then
if value.completedTurnNumber ~= authority.turnLimit then
errors[#errors + 1] = makeError("invalid_aftermath_settlement", path .. ".completedTurnNumber", "정산 중인 자유행동이 도착 턴에 이르지 않았습니다.")
end
elseif value.completedTurnNumber >= authority.turnLimit then
errors[#errors + 1] = makeError("aftermath_over_limit", path .. ".completedTurnNumber", "진행 중인 자유행동이 턴 제한을 넘었습니다.")
end
local needsRequest = value.phase == "inFlight" or value.phase == "requestInjected"
local legacySettlingRequest = value.schemaVersion == 1 and value.phase == "settling"
local request = value.request
if needsRequest or legacySettlingRequest then
if type(request) ~= "table" or getmetatable(request) ~= nil then
if needsRequest then
errors[#errors + 1] = makeError("missing_aftermath_request", path .. ".request", "생성 중인 자유행동 요청 영수증이 없습니다.")
end
else
local requestAllowed = {
turnNumber = true,
userLuaIndex = true,
userFingerprint = true,
attemptNumber = true,
outputObserved = true,
recoveringCleanup = true,
}
for key in pairs(request) do
if type(key) ~= "string" or not requestAllowed[key] then
errors[#errors + 1] = makeError(
"unknown_aftermath_request_field",
path .. ".request." .. tostring(key),
"자유행동 요청 영수증에 알 수 없는 필드가 있습니다."
)
end
end
local expectedTurnNumber = value.phase == "settling"
and value.completedTurnNumber
or value.completedTurnNumber + 1
if request.turnNumber ~= expectedTurnNumber then
errors[#errors + 1] = makeError("aftermath_request_turn_mismatch", path .. ".request.turnNumber", "자유행동 요청 턴이 현재 진행과 다릅니다.")
end
if not isInteger(request.userLuaIndex, 1) then
errors[#errors + 1] = makeError("invalid_aftermath_user_index", path .. ".request.userLuaIndex", "자유행동 사용자 메시지 위치가 올바르지 않습니다.")
end
if request.attemptNumber ~= nil and not isInteger(request.attemptNumber, 1) then
errors[#errors + 1] = makeError("invalid_aftermath_attempt", path .. ".request.attemptNumber", "자유행동 요청 시도 번호가 올바르지 않습니다.")
elseif value.schemaVersion == AFTERMATH_SCHEMA_VERSION and not isInteger(request.attemptNumber, 1) then
errors[#errors + 1] = makeError("missing_aftermath_attempt", path .. ".request.attemptNumber", "자유행동 요청 시도 번호가 없습니다.")
end
validateFingerprint(request.userFingerprint, path .. ".request.userFingerprint", errors)
if request.outputObserved ~= nil then
if value.phase ~= "requestInjected" then
errors[#errors + 1] = makeError("aftermath_output_observed_phase_mismatch", path .. ".request.outputObserved", "자유행동 출력 관측 영수증은 지시가 주입된 요청에만 존재할 수 있습니다.")
end
validateAftermathOutputObserved(request.outputObserved, request, path .. ".request.outputObserved", errors)
end
if request.recoveringCleanup ~= nil then
if value.phase ~= "requestInjected" then
errors[#errors + 1] = makeError("aftermath_recovering_cleanup_phase_mismatch", path .. ".request.recoveringCleanup", "자유행동 응답 삭제 복구 영수증은 지시가 주입된 요청에만 존재할 수 있습니다.")
end
if request.outputObserved ~= nil then
errors[#errors + 1] = makeError("aftermath_recovery_receipt_conflict", path .. ".request", "출력 관측과 응답 삭제 복구 영수증은 함께 존재할 수 없습니다.")
end
validateAftermathRecoveringCleanup(request.recoveringCleanup, request, path .. ".request.recoveringCleanup", errors)
end
end
elseif value.request ~= nil then
errors[#errors + 1] = makeError("unexpected_aftermath_request", path .. ".request", "대기 또는 완료 상태에 생성 요청이 남아 있습니다.")
end
if value.lastCommitted ~= nil then
validateAftermathCommitted(value.lastCommitted, value.completedTurnNumber, path .. ".lastCommitted", errors)
elseif value.schemaVersion == AFTERMATH_SCHEMA_VERSION then
errors[#errors + 1] = makeError("missing_aftermath_committed", path .. ".lastCommitted", "확정된 자유행동 영수증이 없습니다.")
end
return errors
end
local function appendNestedErrors(target, moduleName, report)
if type(report) == "table" and type(report.errors) == "table" then
for index, item in ipairs(report.errors) do
if type(item) == "table" then
target[#target + 1] = makeError(
type(item.code) == "string" and item.code or "nested_error",
type(item.path) == "string" and item.path or ("$.runtime." .. moduleName),
type(item.message) == "string"
and item.message
or (moduleName .. " 오류 " .. index .. "에 설명이 없습니다.")
)
else
target[#target + 1] = makeError(
"invalid_nested_error",
"$.runtime." .. moduleName .. ".errors[" .. index .. "]",
"하위 모듈 오류 항목이 객체가 아닙니다."
)
end
end
end
if #target == 0 then
target[1] = makeError(
"module_failed_without_errors",
"$.runtime." .. moduleName,
moduleName .. "이 오류 상세 없이 실패했습니다."
)
end
end
local function callModule(moduleName, moduleAction, ...)
if type(runScript) ~= "function" then
return nil, {
makeError("runtime_unavailable", "$.runtime", "runScript 실행기를 찾을 수 없습니다."),
}
end
local ok, report = pcall(runScript, triggerId, moduleName, moduleAction, ...)
if not ok then
return nil, {
makeError(
"module_call_error",
"$.runtime." .. moduleName,
moduleName .. "." .. moduleAction .. " 호출 중 오류가 발생했습니다: " .. tostring(report)
),
}
end
if type(report) ~= "table" or getmetatable(report) ~= nil then
return nil, {
makeError(
"invalid_module_result",
"$.runtime." .. moduleName,
moduleName .. "." .. moduleAction .. "이 일반 테이블 결과를 반환하지 않았습니다."
),
}
end
local envelopeVersionOk = report.schemaVersion == SCHEMA_VERSION
or (moduleName == "dataBridge"
and report.schemaVersion == nil
and report.wireFormat == "cbs-json-nodes-v1")
local envelopeErrorsOk = type(report.errors) == "table"
or (moduleName == "dataBridge" and report.ok == true and report.errors == nil)
if not envelopeVersionOk
or not envelopeErrorsOk
or (report.ok ~= true and report.ok ~= false) then
return nil, {
makeError(
"invalid_module_envelope",
"$.runtime." .. moduleName,
moduleName .. "." .. moduleAction .. " 결과 envelope가 올바르지 않습니다."
),
}
end
if report.ok ~= true then
local errors = {}
appendNestedErrors(errors, moduleName, report)
return nil, errors
end
if type(report.errors) == "table" and #report.errors ~= 0 then
return nil, {
makeError(
"success_with_errors",
"$.runtime." .. moduleName .. ".errors",
"성공한 하위 모듈 결과에 오류가 포함되어 있습니다."
),
}
end
return report, nil
end
local function loadStaticData()
local report, errors = callModule("staticData", "loadAll")
if errors then
return nil, errors
end
if type(report.data) ~= "table" or getmetatable(report.data) ~= nil then
return nil, {
makeError("missing_static_data", "$.runtime.staticData.data", "검증된 전체 정적 데이터가 없습니다."),
}
end
return report.data, nil
end
local function readStored(key, required)
if type(HostCompat) ~= "table" or type(HostCompat.readState) ~= "function" then
return nil, {
makeError("state_read_unavailable", "$.host.getState", "상태 읽기 호환 함수를 찾을 수 없습니다."),
}
end
local ok, value = pcall(HostCompat.readState, triggerId, key)
if not ok then
return nil, {
makeError("state_read_failed", "$.state[" .. string.format("%q", key) .. "]", "저장 상태를 읽지 못했습니다: " .. tostring(value)),
}
end
if value == nil then
if required then
return nil, {
makeError("missing_persistent_state", "$.state[" .. string.format("%q", key) .. "]", "필수 저장 상태가 없습니다."),
}
end
return nil, nil
end
local copy, cloneError = cloneJson(value, "$.state[" .. string.format("%q", key) .. "]")
if cloneError then
return nil, { cloneError }
end
return copy, nil
end
local function writeStored(key, value, verify)
if type(HostCompat) ~= "table" or type(HostCompat.writeState) ~= "function" then
return {
makeError("state_write_unavailable", "$.host.setState", "상태 쓰기 호환 함수를 찾을 수 없습니다."),
}
end
if verify == true and type(HostCompat.readState) ~= "function" then
return {
makeError("state_read_unavailable", "$.host.getState", "상태 검증용 읽기 호환 함수를 찾을 수 없습니다."),
}
end
local storedCopy, cloneError = cloneJson(value, "$.state[" .. string.format("%q", key) .. "]")
if cloneError then
return { cloneError }
end
local writeOk, writeError = pcall(HostCompat.writeState, triggerId, key, storedCopy)
if not writeOk then
return {
makeError("state_write_failed", "$.state[" .. string.format("%q", key) .. "]", "저장 상태 쓰기에 실패했습니다: " .. tostring(writeError)),
}
end
if verify ~= true then
return nil
end
local readOk, readValue = pcall(HostCompat.readState, triggerId, key)
if not readOk then
return {
makeError("state_verify_read_failed", "$.state[" .. string.format("%q", key) .. "]", "쓰기 뒤 저장 상태를 다시 읽지 못했습니다: " .. tostring(readValue)),
}
end
if not deepEqual(storedCopy, readValue) then
return {
makeError("state_write_not_persisted", "$.state[" .. string.format("%q", key) .. "]", "쓰기 뒤 읽은 상태가 저장하려던 값과 다릅니다."),
}
end
return nil
end
local function makeTurnId(state)
if type(state) ~= "table" or not isRuntimeId(state.battleId) or not isInteger(state.turnNumber, 1) then
return nil, {
makeError("invalid_turn_identity", "$.authorityState", "battleId와 현재 턴 번호로 turnId를 만들 수 없습니다."),
}
end
return string.format("%s-turn-%03d", state.battleId, state.turnNumber), nil
end
local function expectedPublicMarker(turnNumber)
return "[전투 턴 " .. tostring(turnNumber) .. "] 이번 턴에 실제로 벌어진 일을 하나의 장면으로 이어서 묘사한다."
end
local function validatePromptMessage(message, path)
if type(message) ~= "table" or getmetatable(message) ~= nil then
return makeError("invalid_prompt_message", path, "프롬프트 메시지는 일반 객체여야 합니다.")
end
for key in pairs(message) do
if key ~= "role" and key ~= "content" then
return makeError("unknown_prompt_message_field", path .. "." .. tostring(key), "프롬프트 메시지에 알 수 없는 필드가 있습니다.")
end
end
if message.role ~= "system" or type(message.content) ~= "string" or message.content == "" then
return makeError("invalid_prompt_message", path, "프롬프트 메시지는 비어 있지 않은 system 메시지여야 합니다.")
end
return nil
end
local function formatPending(pendingTurn, staticData)
local report, errors = callModule(
"turnPromptFormatter",
"formatPending",
pendingTurn,
staticData
)
if errors then
return nil, errors
end
local messageError = validatePromptMessage(report.message, "$.runtime.turnPromptFormatter.message")
if messageError then
return nil, { messageError }
end
local turnNumber = type(pendingTurn) == "table"
and type(pendingTurn.beforeState) == "table"
and pendingTurn.beforeState.turnNumber
or nil
if not isInteger(turnNumber, 1) then
return nil, {
makeError("invalid_pending_turn_number", "$.pendingTurn.beforeState.turnNumber", "pendingTurn의 공개 턴 번호가 올바르지 않습니다."),
}
end
if report.publicMarker ~= expectedPublicMarker(turnNumber) then
return nil, {
makeError("invalid_public_marker", "$.runtime.turnPromptFormatter.publicMarker", "formatter가 계약된 공개 턴 마커를 반환하지 않았습니다."),
}
end
local messageCopy, cloneError = cloneJson(report.message, "$.runtime.turnPromptFormatter.message")
if cloneError then
return nil, { cloneError }
end
return {
message = messageCopy,
publicMarker = report.publicMarker,
turnNumber = turnNumber,
}, nil
end
local function validateChatAnchor(anchor, path, errors)
if type(anchor) ~= "table" or getmetatable(anchor) ~= nil then
errors[#errors + 1] = makeError("invalid_chat_anchor", path, "채팅 anchor가 일반 객체가 아닙니다.")
return
end
local allowed = {
schemaVersion = true,
kind = true,
prefixMessageCount = true,
responseIndex = true,
prefixFingerprint = true,
repairableTail = true,
}
for key in pairs(anchor) do
if type(key) ~= "string" or not allowed[key] then
errors[#errors + 1] = makeError("unknown_chat_anchor_field", path .. "." .. tostring(key), "채팅 anchor에 알 수 없는 필드가 있습니다.")
end
end
if anchor.schemaVersion ~= 2 or anchor.kind ~= "battleChatAnchor" then
errors[#errors + 1] = makeError("invalid_chat_anchor_schema", path, "채팅 anchor 스키마가 올바르지 않습니다.")
end
if not isInteger(anchor.prefixMessageCount, 0) then
errors[#errors + 1] = makeError("invalid_chat_anchor_count", path .. ".prefixMessageCount", "anchor prefix 메시지 수가 올바르지 않습니다.")
end
if not isInteger(anchor.responseIndex, 0) or anchor.responseIndex ~= anchor.prefixMessageCount then
errors[#errors + 1] = makeError("invalid_chat_anchor_index", path .. ".responseIndex", "0-based 응답 위치가 prefix 메시지 수와 일치하지 않습니다.")
end
validateFingerprint(anchor.prefixFingerprint, path .. ".prefixFingerprint", errors)
if anchor.repairableTail ~= nil then
local repair = anchor.repairableTail
local repairPath = path .. ".repairableTail"
if type(repair) ~= "table" or getmetatable(repair) ~= nil then
errors[#errors + 1] = makeError("invalid_chat_anchor_repair", repairPath, "복원 가능한 채팅 tail이 일반 객체가 아닙니다.")
else
local repairAllowed = { message = true, prefixFingerprint = true }
for key in pairs(repair) do
if type(key) ~= "string" or not repairAllowed[key] then
errors[#errors + 1] = makeError("unknown_chat_anchor_repair_field", repairPath .. "." .. tostring(key), "복원 가능한 채팅 tail에 알 수 없는 필드가 있습니다.")
end
end
local message = repair.message
if anchor.prefixMessageCount < 1
or type(message) ~= "table"
or getmetatable(message) ~= nil
or message.role ~= "char"
or type(message.data) ~= "string"
or string.match(message.data, "%S") == nil
or message.time ~= 0 then
errors[#errors + 1] = makeError("invalid_chat_anchor_repair_message", repairPath .. ".message", "Lua로 추가된 실제 캐릭터 장면만 anchor tail로 복원할 수 있습니다.")
else
for key in pairs(message) do
if key ~= "role" and key ~= "data" and key ~= "time" then
errors[#errors + 1] = makeError("unknown_chat_anchor_repair_message_field", repairPath .. ".message." .. tostring(key), "복원할 캐릭터 장면에 알 수 없는 필드가 있습니다.")
end
end
end
validateFingerprint(repair.prefixFingerprint, repairPath .. ".prefixFingerprint", errors)
end
end
end
local function validateOutputObserved(receipt, binding, path, errors)
if type(receipt) ~= "table" or getmetatable(receipt) ~= nil then
errors[#errors + 1] = makeError("invalid_output_observed", path, "출력 관측 영수증이 일반 객체가 아닙니다.")
return
end
local allowed = {
schemaVersion = true,
kind = true,
attemptNumber = true,
responseIndex = true,
responseFingerprint = true,
}
for key in pairs(receipt) do
if type(key) ~= "string" or not allowed[key] then
errors[#errors + 1] = makeError("unknown_output_observed_field", path .. "." .. tostring(key), "출력 관측 영수증에 알 수 없는 필드가 있습니다.")
end
end
if receipt.schemaVersion ~= 1 or receipt.kind ~= "battleOutputObserved" then
errors[#errors + 1] = makeError("invalid_output_observed_schema", path, "출력 관측 영수증 스키마가 올바르지 않습니다.")
end
if receipt.attemptNumber ~= binding.attemptNumber then
errors[#errors + 1] = makeError("output_observed_attempt_mismatch", path .. ".attemptNumber", "출력 관측 영수증의 시도 번호가 요청과 다릅니다.")
end
local expectedIndex = type(binding.chatAnchor) == "table" and binding.chatAnchor.responseIndex or nil
if not isInteger(receipt.responseIndex, 0) or receipt.responseIndex ~= expectedIndex then
errors[#errors + 1] = makeError("output_observed_index_mismatch", path .. ".responseIndex", "출력 관측 위치가 채팅 anchor 다음 위치와 다릅니다.")
end
validateFingerprint(receipt.responseFingerprint, path .. ".responseFingerprint", errors)
end
local function validateCleanupReceipt(receipt, binding, path, errors)
if type(receipt) ~= "table" or getmetatable(receipt) ~= nil then
errors[#errors + 1] = makeError("invalid_cleanup_receipt", path, "복구 정리 영수증이 일반 객체가 아닙니다.")
return
end
local allowed = {
schemaVersion = true,
kind = true,
mode = true,
originalPhase = true,
attemptNumber = true,
responseIndex = true,
responsePresent = true,
responseFingerprint = true,
initialFillerCount = true,
}
for key in pairs(receipt) do
if type(key) ~= "string" or not allowed[key] then
errors[#errors + 1] = makeError("unknown_cleanup_receipt_field", path .. "." .. tostring(key), "복구 정리 영수증에 알 수 없는 필드가 있습니다.")
end
end
if receipt.schemaVersion ~= 1 or receipt.kind ~= "battleRecoveringCleanup" then
errors[#errors + 1] = makeError("invalid_cleanup_receipt_schema", path, "복구 정리 영수증 스키마가 올바르지 않습니다.")
end
if receipt.mode ~= "retry" and receipt.mode ~= "resumeCommit" then
errors[#errors + 1] = makeError("invalid_cleanup_mode", path .. ".mode", "복구 정리 mode가 올바르지 않습니다.")
end
if receipt.originalPhase ~= "inFlight" and receipt.originalPhase ~= "requestInjected" then
errors[#errors + 1] = makeError("invalid_cleanup_phase", path .. ".originalPhase", "복구 정리 원본 phase가 올바르지 않습니다.")
elseif receipt.originalPhase ~= binding.phase then
errors[#errors + 1] = makeError("cleanup_phase_mismatch", path .. ".originalPhase", "복구 정리 원본 phase가 현재 요청 phase와 다릅니다.")
end
if receipt.attemptNumber ~= binding.attemptNumber then
errors[#errors + 1] = makeError("cleanup_attempt_mismatch", path .. ".attemptNumber", "복구 정리 시도 번호가 요청과 다릅니다.")
end
local expectedIndex = type(binding.chatAnchor) == "table" and binding.chatAnchor.responseIndex or nil
if not isInteger(receipt.responseIndex, 0) or receipt.responseIndex ~= expectedIndex then
errors[#errors + 1] = makeError("cleanup_response_index_mismatch", path .. ".responseIndex", "복구 정리 응답 위치가 채팅 anchor 다음 위치와 다릅니다.")
end
if type(receipt.responsePresent) ~= "boolean" then
errors[#errors + 1] = makeError("invalid_cleanup_response_flag", path .. ".responsePresent", "복구 정리 응답 존재 표식이 불리언이 아닙니다.")
elseif receipt.responsePresent then
validateFingerprint(receipt.responseFingerprint, path .. ".responseFingerprint", errors)
elseif receipt.responseFingerprint ~= nil then
errors[#errors + 1] = makeError("unexpected_cleanup_response_fingerprint", path .. ".responseFingerprint", "응답이 없는 복구 정리에 응답 fingerprint가 있습니다.")
end
if not isInteger(receipt.initialFillerCount, 1) then
errors[#errors + 1] = makeError("invalid_cleanup_filler_count", path .. ".initialFillerCount", "복구 정리에는 최초 filler가 하나 이상 필요합니다.")
end
if receipt.mode == "retry" and binding.outputObserved ~= nil then
errors[#errors + 1] = makeError("retry_cleanup_has_observed_output", path .. ".mode", "출력이 관측된 요청을 삭제 재시도로 정리할 수 없습니다.")
elseif receipt.mode == "resumeCommit" and binding.outputObserved == nil then
errors[#errors + 1] = makeError("commit_cleanup_missing_observed_output", path .. ".mode", "commit 재개 정리에는 출력 관측 영수증이 필요합니다.")
end
end
local function buildBinding(pendingTurn, formatted, sourceName, phase, chatAnchor, attemptNumber)
if sourceName ~= "pending" and sourceName ~= "lastCommittedPending" then
return nil, {
makeError("invalid_request_source", "$.activeRequest.source", "알 수 없는 요청 source입니다."),
}
end
if phase ~= "preparing"
and phase ~= "inFlight"
and phase ~= "requestInjected"
and phase ~= "committed" then
return nil, {
makeError("invalid_request_phase", "$.activeRequest.phase", "알 수 없는 요청 phase입니다."),
}
end
if phase == "committed" and sourceName ~= "lastCommittedPending" then
return nil, {
makeError("committed_request_source_mismatch", "$.activeRequest.source", "확정 요청은 lastCommittedPending을 가리켜야 합니다."),
}
end
if type(pendingTurn) ~= "table"
or not isRuntimeId(pendingTurn.battleId)
or not isRuntimeId(pendingTurn.turnId) then
return nil, {
makeError("invalid_pending_identity", "$.pendingTurn", "요청에 연결할 pendingTurn 식별자가 올바르지 않습니다."),
}
end
return {
schemaVersion = ACTIVE_REQUEST_SCHEMA_VERSION,
kind = "battleActiveRequest",
battleId = pendingTurn.battleId,
turnId = pendingTurn.turnId,
turnNumber = formatted.turnNumber,
source = sourceName,
phase = phase,
attemptNumber = attemptNumber or 1,
publicMarker = formatted.publicMarker,
message = formatted.message,
chatAnchor = chatAnchor,
}, nil
end
local function validateBinding(binding, pendingTurn, path)
path = path or "$.activeRequest"
if type(binding) ~= "table" or getmetatable(binding) ~= nil then
return { makeError("invalid_active_request", path, "활성 요청 binding이 일반 객체가 아닙니다.") }
end
local allowed = {
schemaVersion = true,
kind = true,
battleId = true,
turnId = true,
turnNumber = true,
source = true,
phase = true,
publicMarker = true,
message = true,
attemptNumber = true,
chatAnchor = true,
outputObserved = true,
recoveringCleanup = true,
}
local errors = {}
for key in pairs(binding) do
if type(key) ~= "string" or not allowed[key] then
errors[#errors + 1] = makeError("unknown_active_request_field", path .. "." .. tostring(key), "활성 요청 binding에 알 수 없는 필드가 있습니다.")
end
end
if binding.schemaVersion ~= ACTIVE_REQUEST_SCHEMA_VERSION or binding.kind ~= "battleActiveRequest" then
errors[#errors + 1] = makeError("invalid_active_request_schema", path, "활성 요청 binding 스키마가 올바르지 않습니다.")
end
if not isRuntimeId(binding.battleId) or not isRuntimeId(binding.turnId) then
errors[#errors + 1] = makeError("invalid_active_request_identity", path, "활성 요청 식별자가 올바르지 않습니다.")
end
if not isInteger(binding.turnNumber, 1) then
errors[#errors + 1] = makeError("invalid_active_request_turn", path .. ".turnNumber", "활성 요청 턴 번호가 올바르지 않습니다.")
end
if not isInteger(binding.attemptNumber, 1) then
errors[#errors + 1] = makeError("invalid_request_attempt", path .. ".attemptNumber", "활성 요청 시도 번호가 올바르지 않습니다.")
end
if binding.source ~= "pending" and binding.source ~= "lastCommittedPending" then
errors[#errors + 1] = makeError("invalid_request_source", path .. ".source", "활성 요청 source가 올바르지 않습니다.")
end
if binding.phase ~= "preparing"
and binding.phase ~= "inFlight"
and binding.phase ~= "requestInjected"
and binding.phase ~= "committed" then
errors[#errors + 1] = makeError("invalid_request_phase", path .. ".phase", "활성 요청 phase가 올바르지 않습니다.")
elseif binding.phase == "committed" and binding.source ~= "lastCommittedPending" then
errors[#errors + 1] = makeError("committed_request_source_mismatch", path .. ".source", "확정 요청은 lastCommittedPending을 가리켜야 합니다.")
end
if binding.publicMarker ~= expectedPublicMarker(binding.turnNumber) then
errors[#errors + 1] = makeError("invalid_public_marker", path .. ".publicMarker", "활성 요청의 공개 마커가 턴 번호와 일치하지 않습니다.")
end
local messageError = validatePromptMessage(binding.message, path .. ".message")
if messageError then
errors[#errors + 1] = messageError
end
validateChatAnchor(binding.chatAnchor, path .. ".chatAnchor", errors)
if binding.outputObserved ~= nil then
if binding.phase ~= "requestInjected" then
errors[#errors + 1] = makeError("output_observed_phase_mismatch", path .. ".outputObserved", "출력 관측 영수증은 requestInjected phase에만 존재할 수 있습니다.")
end
validateOutputObserved(binding.outputObserved, binding, path .. ".outputObserved", errors)
end
if binding.recoveringCleanup ~= nil then
if binding.phase ~= "inFlight" and binding.phase ~= "requestInjected" then
errors[#errors + 1] = makeError("cleanup_receipt_phase_mismatch", path .. ".recoveringCleanup", "복구 정리 영수증은 잠긴 요청 phase에만 존재할 수 있습니다.")
end
validateCleanupReceipt(binding.recoveringCleanup, binding, path .. ".recoveringCleanup", errors)
end
if binding.phase == "committed" and (binding.outputObserved ~= nil or binding.recoveringCleanup ~= nil) then
errors[#errors + 1] = makeError("committed_request_has_transient_receipt", path, "확정 요청에 임시 출력·정리 영수증이 남아 있습니다.")
end
if type(pendingTurn) == "table" then
local pendingTurnNumber = type(pendingTurn.beforeState) == "table" and pendingTurn.beforeState.turnNumber or nil
if binding.battleId ~= pendingTurn.battleId
or binding.turnId ~= pendingTurn.turnId
or binding.turnNumber ~= pendingTurnNumber then
errors[#errors + 1] = makeError("active_request_pending_mismatch", path, "활성 요청이 선택한 pendingTurn과 일치하지 않습니다.")
end
end
return errors
end
local function readChat()
if type(getFullChat) ~= "function" then
return nil, {
makeError("chat_read_unavailable", "$.host.getFullChat", "getFullChat 호스트 함수를 찾을 수 없습니다."),
}
end
local ok, chat = pcall(getFullChat, triggerId)
if not ok then
return nil, {
makeError("chat_read_failed", "$.chat", "대화 기록을 읽지 못했습니다: " .. tostring(chat)),
}
end
local copy, cloneError = cloneJson(chat, "$.chat")
if cloneError then
return nil, { cloneError }
end
local count = 0
for key in pairs(copy) do
if type(key) ~= "number" or not isInteger(key, 1) then
return nil, {
makeError("invalid_chat_array", "$.chat", "대화 기록은 1부터 이어지는 배열이어야 합니다."),
}
end
count = count + 1
end
if count ~= #copy then
return nil, {
makeError("invalid_chat_array", "$.chat", "대화 기록 배열에 빈 인덱스가 있습니다."),
}
end
return copy, nil
end
local function inspectDraftInteractionToken(authority, staticData, draft)
local inspected, inspectErrors = callModule(
"turnDraft",
"inspect",
authority,
staticData,
draft
)
if inspectErrors then return nil, inspectErrors end
if type(inspected.interactionToken) ~= "string" or inspected.interactionToken == "" then
return nil, {
makeError("missing_interaction_token", "$.runtime.turnDraft.inspect", "현재 turnDraft의 interaction token을 만들지 못했습니다."),
}
end
return inspected.interactionToken, nil
end
local function readArmedSubmission(authority, staticData, draft)
local receipt, readErrors = readStored(KEYS.submission, false)
if readErrors then return nil, readErrors end
if receipt == nil then return nil, nil end
if type(receipt) ~= "string" or receipt == "" then
return nil, {
makeError("invalid_submission", "$.submission", "턴 제출 변수는 비어 있지 않은 interaction token이어야 합니다."),
}
end
local interactionToken, tokenErrors = inspectDraftInteractionToken(authority, staticData, draft)
if tokenErrors then return nil, tokenErrors end
if receipt ~= interactionToken then
return nil, {
makeError("stale_submission", "$.submission", "턴 제출 변수가 현재 카드 선택과 일치하지 않습니다."),
}
end
return receipt, nil
end
local function clearSubmission()
return writeStored(KEYS.submission, nil)
end
local function isExactSayNothing(message)
return type(message) == "table"
and message.role == "user"
and message.data == SAY_NOTHING
end
local function isExactTurnSubmitMarker(message)
return type(message) == "table"
and message.role == "user"
and message.data == TURN_SUBMIT_MARKER
end
local function isExactFiller(message)
return isExactSayNothing(message) or isExactTurnSubmitMarker(message)
end
local function isRisuErrorResponse(message)
return type(message) == "table"
and message.role == "char"
and type(message.data) == "string"
and string.find(message.data, "```risuerror", 1, true) ~= nil
end
local function createChatAnchor(chat, prefixMessageCount)
local fingerprint, fingerprintError = fingerprintChatRange(
chat,
1,
prefixMessageCount,
"$.chatAnchor.prefix"
)
if fingerprintError then return nil, { fingerprintError } end
local anchor = {
schemaVersion = 2,
kind = "battleChatAnchor",
prefixMessageCount = prefixMessageCount,
responseIndex = prefixMessageCount,
prefixFingerprint = fingerprint,
}
local tail = chat[prefixMessageCount]
if prefixMessageCount > 0
and type(tail) == "table"
and tail.role == "char"
and type(tail.data) == "string"
and string.match(tail.data, "%S") ~= nil
and tail.time == 0 then
local previousFingerprint, previousFingerprintError = fingerprintChatRange(
chat,
1,
prefixMessageCount - 1,
"$.chatAnchor.repairablePrefix"
)
if previousFingerprintError then return nil, { previousFingerprintError } end
local tailCopy, tailCopyError = cloneJson(tail, "$.chatAnchor.repairableTail.message")
if tailCopyError then return nil, { tailCopyError } end
anchor.repairableTail = {
message = tailCopy,
prefixFingerprint = previousFingerprint,
}
end
return anchor, nil
end
local function createPlannedChatAnchor(chat)
local logicalLength = #chat
while logicalLength > 0 and isExactSayNothing(chat[logicalLength]) do
logicalLength = logicalLength - 1
end
return createChatAnchor(chat, logicalLength)
end
local function validateAnchorPrefix(binding, chat)
local anchor = binding.chatAnchor
local prefixCount = anchor.prefixMessageCount
if #chat < prefixCount then
return {
makeError("chat_anchor_prefix_missing", "$.chat", "현재 대화가 저장된 채팅 anchor prefix보다 짧습니다."),
}
end
local fingerprint, fingerprintError = fingerprintChatRange(
chat,
1,
prefixCount,
"$.chatAnchor.currentPrefix"
)
if fingerprintError then return { fingerprintError } end
if not fingerprintsEqual(fingerprint, anchor.prefixFingerprint) then
return {
makeError("chat_anchor_prefix_mismatch", "$.chat", "요청 이전 대화가 요청 준비 시점의 채팅 anchor와 다릅니다."),
}
end
return nil
end
local function restoreRepairableAnchorTail(binding, chat)
local anchor = binding.chatAnchor
if #chat >= anchor.prefixMessageCount then
return chat, false, nil
end
local repair = anchor.repairableTail
if type(repair) ~= "table" or #chat ~= anchor.prefixMessageCount - 1 then
return chat, false, nil
end
local fingerprint, fingerprintError = fingerprintChatRange(
chat,
1,
#chat,
"$.chatAnchor.repairableCurrentPrefix"
)
if fingerprintError then return nil, false, { fingerprintError } end
if not fingerprintsEqual(fingerprint, repair.prefixFingerprint) then
return chat, false, nil
end
if type(addChat) ~= "function" then
return nil, false, {
makeError("chat_write_unavailable", "$.host.addChat", "리롤이 함께 제거한 접근 장면을 복원할 addChat 호스트 함수를 찾을 수 없습니다."),
}
end
local ok, addError = pcall(addChat, triggerId, "char", repair.message.data)
if not ok then
return nil, false, {
makeError("chat_anchor_repair_failed", "$.chat", "리롤이 함께 제거한 접근 장면을 복원하지 못했습니다: " .. tostring(addError)),
}
end
local restored, readErrors = readChat()
if readErrors then return nil, false, readErrors end
local prefixErrors = validateAnchorPrefix(binding, restored)
if prefixErrors then
return nil, false, prefixErrors
end
return restored, true, nil
end
local function inspectPreparingTopology(binding, chat)
local prefixErrors = validateAnchorPrefix(binding, chat)
if prefixErrors then return nil, prefixErrors end
local cursor = binding.chatAnchor.prefixMessageCount + 1
local fillerCount = 0
for index = cursor, #chat do
if not isExactFiller(chat[index]) then
return nil, {
makeError("preparing_chat_topology_mismatch", "$.chat[" .. index .. "]", "준비 중인 요청의 기준 대화 뒤에는 trailing filler만 올 수 있습니다."),
}
end
fillerCount = fillerCount + 1
end
return {
fillerCount = fillerCount,
}, nil
end
local function inspectAnchoredTopology(binding, chat, requireFiller)
local prefixErrors = validateAnchorPrefix(binding, chat)
if prefixErrors then return nil, prefixErrors end
local prefixCount = binding.chatAnchor.prefixMessageCount
local fillerCount = 0
local cursor = #chat
while cursor > prefixCount and isExactFiller(chat[cursor]) do
fillerCount = fillerCount + 1
cursor = cursor - 1
end
if requireFiller and fillerCount == 0 then
return nil, {
makeError("recovery_filler_missing", "$.chat", "잠긴 요청을 복구하려면 새 정확한 빈 입력이 필요합니다."),
}
end
local responsePresent = false
local responseFingerprint
local responseLuaIndex = prefixCount + 1
if cursor == responseLuaIndex then
local response = chat[responseLuaIndex]
if type(response) ~= "table" or response.role ~= "char" then
return nil, {
makeError("recovery_response_role_mismatch", "$.chat[" .. responseLuaIndex .. "]", "요청 응답 위치의 미확정 메시지가 캐릭터 메시지가 아닙니다."),
}
end
responsePresent = true
local fingerprintError
responseFingerprint, fingerprintError = fingerprintChatRange(
chat,
responseLuaIndex,
1,
"$.recovery.response"
)
if fingerprintError then return nil, { fingerprintError } end
elseif cursor ~= prefixCount then
return nil, {
makeError("recovery_chat_topology_mismatch", "$.chat", "요청 기준 대화 뒤에 자동 복구가 소유한다고 증명할 수 없는 메시지가 있습니다."),
}
end
return {
responseLuaIndex = responseLuaIndex,
responseIndex = binding.chatAnchor.responseIndex,
responsePresent = responsePresent,
responseFingerprint = responseFingerprint,
fillerCount = fillerCount,
}, nil
end
local function inspectObservedOutput(binding, chat, allowTrailingFillers)
local topology, topologyErrors = inspectAnchoredTopology(binding, chat, false)
if topologyErrors then return nil, topologyErrors end
if not topology.responsePresent then
return nil, {
makeError("observed_output_missing", "$.chat", "출력 관측 영수증에 연결할 캐릭터 응답이 없습니다."),
}
end
if not allowTrailingFillers and topology.fillerCount ~= 0 then
return nil, {
makeError("unexpected_output_suffix", "$.chat", "정상 onOutput 관측 시점에 응답 뒤 추가 메시지가 있습니다."),
}
end
local observed = binding.outputObserved
if observed ~= nil then
if topology.responseIndex ~= observed.responseIndex
or not fingerprintsEqual(topology.responseFingerprint, observed.responseFingerprint) then
return nil, {
makeError("observed_output_mismatch", "$.chat", "현재 캐릭터 응답이 저장된 출력 관측 영수증과 다릅니다."),
}
end
end
return topology, nil
end
local function removeTrailingSayNothing(chat, minimumLength)
local current = chat
local removedCount = 0
while true do
local last = current[#current]
if #current <= (minimumLength or 0)
or not isExactSayNothing(last) then
return current, removedCount, nil
end
if type(removeChat) ~= "function" then
return nil, removedCount, {
makeError("chat_write_unavailable", "$.host.removeChat", "removeChat 호스트 함수를 찾을 수 없습니다."),
}
end
local ok, removeError = pcall(removeChat, triggerId, #current - 1)
if not ok then
return nil, removedCount, {
makeError("say_nothing_remove_failed", "$.chat[" .. #current .. "]", "자동 제출 메시지를 제거하지 못했습니다: " .. tostring(removeError)),
}
end
local after, readErrors = readChat()
if readErrors then
return nil, removedCount, readErrors
end
if #after ~= #current - 1 then
return nil, removedCount, {
makeError("say_nothing_remove_not_persisted", "$.chat", "자동 제출 메시지 제거 뒤 대화 길이가 바뀌지 않았습니다."),
}
end
for index = 1, #after do
if not deepEqual(after[index], current[index]) then
return nil, removedCount, {
makeError("say_nothing_remove_mismatch", "$.chat[" .. index .. "]", "자동 제출 메시지 제거가 앞선 대화를 변경했습니다."),
}
end
end
current = after
removedCount = removedCount + 1
end
end
local function ensureTurnBoundary(chat)
local last = chat[#chat]
if type(last) == "table" and last.role == "user" then
return chat, false, nil
end
if type(addChat) ~= "function" then
return nil, false, {
makeError("chat_write_unavailable", "$.host.addChat", "리롤 경계 메시지를 추가할 addChat 호스트 함수를 찾을 수 없습니다."),
}
end
local ok, addError = pcall(addChat, triggerId, "user", TURN_SUBMIT_MARKER)
if not ok then
return nil, false, {
makeError("turn_boundary_add_failed", "$.chat", "리롤 경계 메시지를 추가하지 못했습니다: " .. tostring(addError)),
}
end
local after, readErrors = readChat()
if readErrors then return nil, false, readErrors end
if #after ~= #chat + 1 or not isExactFiller(after[#after])
or after[#after].data ~= TURN_SUBMIT_MARKER then
return nil, false, {
makeError("turn_boundary_add_not_persisted", "$.chat", "리롤 경계 메시지 추가가 저장되지 않았습니다."),
}
end
for index = 1, #chat do
if not deepEqual(after[index], chat[index]) then
return nil, false, {
makeError("turn_boundary_add_mismatch", "$.chat[" .. index .. "]", "리롤 경계 메시지 추가가 앞선 대화를 변경했습니다."),
}
end
end
return after, true, nil
end
local function removeRecoveryResponse(chat, luaIndex, expectedFingerprint)
if luaIndex ~= #chat then
return nil, {
makeError("recovery_response_not_last", "$.chat", "filler 정리 뒤 삭제할 미확정 응답이 마지막 메시지가 아닙니다."),
}
end
local currentFingerprint, fingerprintError = fingerprintChatRange(
chat,
luaIndex,
1,
"$.recovery.response"
)
if fingerprintError then return nil, { fingerprintError } end
if not fingerprintsEqual(currentFingerprint, expectedFingerprint) then
return nil, {
makeError("recovery_response_fingerprint_mismatch", "$.chat[" .. luaIndex .. "]", "삭제 대상 응답이 복구 영수증과 다릅니다."),
}
end
if type(chat[luaIndex]) ~= "table" or chat[luaIndex].role ~= "char" then
return nil, {
makeError("recovery_response_role_mismatch", "$.chat[" .. luaIndex .. "]", "삭제 대상이 캐릭터 응답이 아닙니다."),
}
end
if type(removeChat) ~= "function" then
return nil, {
makeError("chat_write_unavailable", "$.host.removeChat", "removeChat 호스트 함수를 찾을 수 없습니다."),
}
end
local ok, removeError = pcall(removeChat, triggerId, luaIndex - 1)
if not ok then
return nil, {
makeError("recovery_response_remove_failed", "$.chat[" .. luaIndex .. "]", "미확정 응답을 삭제하지 못했습니다: " .. tostring(removeError)),
}
end
local after, readErrors = readChat()
if readErrors then return nil, readErrors end
if #after ~= #chat - 1 then
return nil, {
makeError("recovery_response_remove_not_persisted", "$.chat", "미확정 응답 삭제 뒤 대화 길이가 바뀌지 않았습니다."),
}
end
for index = 1, #after do
if not deepEqual(after[index], chat[index]) then
return nil, {
makeError("recovery_response_remove_mismatch", "$.chat[" .. index .. "]", "미확정 응답 삭제가 앞선 대화를 변경했습니다."),
}
end
end
return after, nil
end
local function publishCurrentViewInternal(staticData, suppressRefresh, skipUiRender, interactionOnly)
staticData = staticData or select(1, loadStaticData())
if staticData == nil then
local _, errors = loadStaticData()
return nil, errors
end
local authority, authorityErrors = readStored(KEYS.authority, true)
if authorityErrors then
return nil, authorityErrors
end
local context = {}
local lastCommitted, lastCommittedErrors = readStored(KEYS.lastCommittedPending, false)
if lastCommittedErrors then
return nil, lastCommittedErrors
end
if authority.lastCommittedTurnId ~= nil and lastCommitted == nil then
return nil, {
makeError(
"missing_last_committed_pending",
"$.lastCommittedPending",
"확정 turnId가 있는 authority에는 공개 결과를 재현할 직전 pendingTurn이 필요합니다."
),
}
end
if lastCommitted ~= nil then
context.lastCommittedPending = lastCommitted
end
local activeRequest, activeRequestErrors = readStored(KEYS.activeRequest, false)
if activeRequestErrors then
return nil, activeRequestErrors
end
if activeRequest ~= nil then
local activeValidationErrors = validateBinding(activeRequest, nil)
if #activeValidationErrors > 0 then
return nil, activeValidationErrors
end
end
local aftermath, aftermathErrors = readStored(KEYS.aftermath, false)
if aftermathErrors then
return nil, aftermathErrors
end
if aftermath ~= nil then
local aftermathValidationErrors = validateAftermath(aftermath, authority)
if #aftermathValidationErrors > 0 then
return nil, aftermathValidationErrors
end
if aftermath.phase == "ready"
or aftermath.phase == "inFlight"
or aftermath.phase == "requestInjected" then
context.aftermath = {
completedTurnNumber = aftermath.completedTurnNumber,
phase = aftermath.phase,
}
end
end
if authority.status == "active" then
local pending, pendingErrors = readStored(KEYS.pending, false)
if pendingErrors then
return nil, pendingErrors
end
if pending ~= nil then
context.pendingTurn = pending
else
local draft, draftErrors = readStored(KEYS.draft, true)
if draftErrors then
return nil, draftErrors
end
context.draft = draft
if type(activeRequest) == "table"
and (activeRequest.phase == "preparing"
or activeRequest.phase == "inFlight"
or activeRequest.phase == "requestInjected") then
context.generationLocked = true
else
local submission, submissionErrors = readArmedSubmission(
authority,
staticData,
draft
)
if submissionErrors then return nil, submissionErrors end
if submission ~= nil then
context.submissionArmed = true
end
end
end
end
local built, buildErrors = callModule(
"viewBuilder",
interactionOnly == true and "buildBattleInteractionView" or "buildBattleView",
authority,
staticData,
context
)
if buildErrors then
return nil, buildErrors
end
if type(built.view) ~= "table" then
return nil, {
makeError("missing_battle_view", "$.runtime.viewBuilder.view", "viewBuilder 성공 결과에 battleView가 없습니다."),
}
end
local interactionView = interactionOnly == true and built.view or built.interactionView
if type(interactionView) ~= "table" then
return nil, {
makeError(
"missing_battle_interaction_view",
"$.runtime.viewBuilder.interactionView",
"viewBuilder 성공 결과에 battleInteractionView가 없습니다."
),
}
end
local publishedViewName = interactionOnly == true and INTERACTION_VIEW_NAME or VIEW_NAME
local publishedView = interactionOnly == true and interactionView or built.view
local published, publishErrors = callModule(
"dataBridge",
"_publishCanonical",
publishedViewName,
publishedView,
permitCanonicalBattleView
)
if publishErrors then
return nil, publishErrors
end
if type(published.encoded) ~= "string" or published.encoded == "" then
return nil, {
makeError("missing_published_view", "$.runtime.dataBridge.encoded", "dataBridge 성공 결과에 게시 문자열이 없습니다."),
}
end
if skipUiRender == true then
return {
view = publishedView,
wireFormat = published.wireFormat,
bytes = published.bytes,
}, nil
end
-- getLoreBooks/loadLores는 HTML을 반환하기 전에 CBS를 평가하므로
-- View를 먼저 쓴 뒤 UI 조각을 로드한다.
if type(loadLores) ~= "function" then
return nil, {
makeError("lore_loader_unavailable", "$.host.loadLores", "전투 UI 로어북을 읽을 loadLores 함수를 찾을 수 없습니다."),
}
end
if type(HostCompat) ~= "table" or type(HostCompat.writeChatVar) ~= "function"
or type(getChatVar) ~= "function" then
return nil, {
makeError("ui_write_unavailable", "$.host.setChatVar", "전투 UI를 게시할 호환 쓰기/getChatVar 함수를 찾을 수 없습니다."),
}
end
if interactionOnly ~= true then
local interactionPublished, interactionPublishErrors = callModule(
"dataBridge",
"_publishCanonical",
INTERACTION_VIEW_NAME,
interactionView,
permitCanonicalBattleView
)
if interactionPublishErrors then
return nil, interactionPublishErrors
end
if type(interactionPublished.encoded) ~= "string"
or interactionPublished.encoded == "" then
return nil, {
makeError(
"missing_published_view",
"$.runtime.dataBridge.encoded",
"dataBridge 성공 결과에 interaction 게시 문자열이 없습니다."
),
}
end
end
local frameUi = nil
if interactionOnly == true then
local frameReadOk, storedFrame = pcall(getChatVar, triggerId, UI_BODY_NAME)
if frameReadOk
and type(storedFrame) == "string"
and string.find(storedFrame, UI_INTERACTION_MARKER, 1, true) ~= nil then
frameUi = storedFrame
else
return nil, {
makeError("missing_ui_frame", "$.chatVar.battleUi", "카드 상호작용을 반영할 현재 턴의 전투 UI 프레임이 없습니다."),
}
end
end
if interactionOnly ~= true then
local frameLoadOk, loadedFrame = pcall(loadLores, triggerId, "battleui.html")
if not frameLoadOk then
return nil, {
makeError("lore_load_failed", "$.lore.battleui", "battleui.html을 읽지 못했습니다: " .. tostring(loadedFrame)),
}
end
if type(loadedFrame) ~= "string"
or loadedFrame == ""
or string.find(loadedFrame, UI_INTERACTION_MARKER, 1, true) == nil then
return nil, {
makeError("invalid_ui_frame", "$.lore.battleui", "battleui.html 고정 프레임에 상호작용 마커가 없습니다."),
}
end
frameUi = loadedFrame
end
local interactionLoadOk, interactionUi = pcall(loadLores, triggerId, "battleui-interaction.html")
if not interactionLoadOk then
return nil, {
makeError("lore_load_failed", "$.lore.battleuiInteraction", "battleui-interaction.html을 읽지 못했습니다: " .. tostring(interactionUi)),
}
end
if type(interactionUi) ~= "string" or interactionUi == "" then
return nil, {
makeError("missing_lore", "$.lore.battleuiInteraction", "battleui-interaction.html 로어북 내용이 없습니다."),
}
end
if interactionOnly ~= true then
local frameWriteOk, frameWriteError = pcall(HostCompat.writeChatVar, triggerId, UI_BODY_NAME, frameUi)
if not frameWriteOk then
return nil, {
makeError("ui_write_failed", "$.chatVar.battleUi", "전투 UI 프레임 쓰기에 실패했습니다: " .. tostring(frameWriteError)),
}
end
end
local interactionWriteOk, interactionWriteError = pcall(
HostCompat.writeChatVar,
triggerId,
UI_INTERACTION_NAME,
interactionUi
)
if not interactionWriteOk then
return nil, {
makeError("ui_write_failed", "$.chatVar.battleUiInteraction", "전투 UI 상호작용 조각 쓰기에 실패했습니다: " .. tostring(interactionWriteError)),
}
end
if suppressRefresh ~= true then
local uiRefresh = type(refreshGameUi) == "function" and refreshGameUi or reloadDisplay
if type(uiRefresh) ~= "function" then
return nil, {
makeError("display_reload_unavailable", "$.host.refreshGameUi", "게시한 battleView를 화면에 반영할 UI 갱신 함수가 없습니다."),
}
end
local reloadOk, reloadError = pcall(uiRefresh, triggerId)
if not reloadOk then
return nil, {
makeError("display_reload_failed", "$.host.refreshGameUi", "battleView 게시 뒤 화면 갱신에 실패했습니다: " .. tostring(reloadError)),
}
end
end
return {
view = publishedView,
wireFormat = published.wireFormat,
bytes = published.bytes,
uiFrame = frameUi,
uiInteraction = interactionUi,
interactionOnly = interactionOnly == true,
}, nil
end
local function publishTerminalBattleLog(authority, staticData)
if type(authority) ~= "table"
or (authority.status ~= "victory" and authority.status ~= "defeat") then
return nil, {
makeError("battle_log_requires_terminal", "$.authority.status", "종료된 전투에서만 상세 로그를 게시할 수 있습니다."),
}
end
local built, buildErrors = callModule(
"battleHistory",
"buildPublicView",
authority.history,
staticData
)
if buildErrors then return nil, buildErrors end
if type(built.view) ~= "table" or built.view.available ~= true then
return nil, {
makeError("missing_terminal_battle_log", "$.runtime.battleHistory.view", "종료 전투의 공개 상세 로그를 만들지 못했습니다."),
}
end
local published, publishErrors = callModule(
"dataBridge",
"_publishCanonical",
BATTLE_LOG_VIEW_NAME,
built.view,
permitCanonicalBattleView
)
if publishErrors then return nil, publishErrors end
if type(published.encoded) ~= "string" or published.encoded == "" then
return nil, {
makeError("missing_published_battle_log", "$.runtime.dataBridge.encoded", "상세 전투 로그 게시 문자열이 없습니다."),
}
end
return built.view, nil
end
local function validateBattleReadySetup(setupState, staticData)
local setupCopy, setupCopyError = cloneJson(setupState, "$.setupState")
if setupCopyError then
return nil, nil, { setupCopyError }
end
local validated, validationErrors = callModule(
"gameSetup",
"validate",
setupCopy,
staticData
)
if validationErrors then
return nil, nil, validationErrors
end
if type(validated.state) ~= "table" or getmetatable(validated.state) ~= nil then
return nil, nil, {
makeError("missing_validated_setup", "$.runtime.gameSetup.state", "gameSetup.validate가 정규 설정 상태를 반환하지 않았습니다."),
}
end
if not deepEqual(setupCopy, validated.state) then
return nil, nil, {
makeError("setup_validation_mismatch", "$.setupState", "설정 상태가 gameSetup.validate의 정규 상태와 정확히 일치하지 않습니다."),
}
end
if validated.state.phase ~= "battleReady" then
return nil, nil, {
makeError("setup_not_battle_ready", "$.setupState.phase", "캐릭터 선택까지 끝난 battleReady 설정만 전투를 시작할 수 있습니다."),
}
end
local battleSpec = validated.state.battleSpec
if type(battleSpec) ~= "table" or getmetatable(battleSpec) ~= nil then
return nil, nil, {
makeError("missing_battle_spec", "$.setupState.battleSpec", "battleReady 설정에 전투 사양이 없습니다."),
}
end
local playerCardIds, deckCopyError = cloneJson(
validated.state.selectedCardIds,
"$.setupState.selectedCardIds"
)
if deckCopyError then
return nil, nil, { deckCopyError }
end
local normalizedSetup, normalizedError = cloneJson(validated.state, "$.setupState")
if normalizedError then
return nil, nil, { normalizedError }
end
return normalizedSetup, {
battleId = battleSpec.battleId,
seed = battleSpec.seed,
playerCardIds = playerCardIds,
characterId = validated.state.selectedCharacterId,
}, nil
end
local function inspectCanonicalBattleReadySetup(setupState)
if type(setupState) ~= "table"
or getmetatable(setupState) ~= nil
or setupState.schemaVersion ~= SCHEMA_VERSION
or setupState.kind ~= "gameSetupV1"
or setupState.phase ~= "battleReady"
or not isRuntimeId(setupState.setupId)
or type(setupState.selectedCardIds) ~= "table"
or type(setupState.selectedCharacterId) ~= "string"
or type(setupState.battleSpec) ~= "table"
or getmetatable(setupState.battleSpec) ~= nil
or not isRuntimeId(setupState.battleSpec.battleId)
or not isInteger(setupState.battleSpec.seed, 1) then
return nil, nil, {
makeError(
"invalid_canonical_setup_contract",
"$.setupState",
"내부 canonical battleReady 설정이 필수 postcondition을 만족하지 않습니다."
),
}
end
return setupState, {
battleId = setupState.battleSpec.battleId,
seed = setupState.battleSpec.seed,
playerCardIds = setupState.selectedCardIds,
characterId = setupState.selectedCharacterId,
}, nil
end
local function buildInitialBattleFromSetup(spec, staticData)
local bootstrap, bootstrapErrors = callModule(
"battleBootstrap",
"fromSetup",
spec,
staticData
)
if bootstrapErrors then
return nil, bootstrapErrors
end
if bootstrap.referencesValidated ~= true or type(bootstrap.state) ~= "table" then
return nil, {
makeError("invalid_setup_bootstrap_result", "$.runtime.battleBootstrap", "fromSetup이 검증된 battleState를 반환하지 않았습니다."),
}
end
local turnId, turnIdErrors = makeTurnId(bootstrap.state)
if turnIdErrors then
return nil, turnIdErrors
end
local initialized, initializeErrors = callModule(
"turnInitializer",
"prepareTurn",
bootstrap.state,
staticData,
{ turnId = turnId }
)
if initializeErrors then
return nil, initializeErrors
end
if type(initialized.state) ~= "table" or type(initialized.draft) ~= "table" then
return nil, {
makeError("invalid_initial_turn", "$.runtime.turnInitializer", "setup 전투의 첫 턴 상태와 draft가 없습니다."),
}
end
if initialized.state.battleId ~= spec.battleId then
return nil, {
makeError("initialized_battle_id_mismatch", "$.runtime.turnInitializer.state.battleId", "첫 턴 상태의 battleId가 설정 사양과 다릅니다."),
}
end
return {
authority = initialized.state,
draft = initialized.draft,
pending = nil,
lastCommittedPending = nil,
activeRequest = nil,
submission = nil,
aftermath = nil,
turnId = turnId,
}, nil
end
local function readRuntimeBundle()
local values = {}
for _, name in ipairs({
"authority",
"draft",
"pending",
"lastCommittedPending",
"activeRequest",
"submission",
"aftermath",
}) do
local value, errors = readStored(KEYS[name], false)
if errors then
return nil, errors
end
values[name] = value
end
return values, nil
end
local function buildTerminalSummary(authority, lastCommitted, staticData)
if type(authority) ~= "table"
or (authority.status ~= "victory" and authority.status ~= "defeat") then
return nil, {
makeError(
"battle_not_terminal",
"$.state.authority.status",
"승리 또는 패배로 확정된 전투만 정산할 수 있습니다."
),
}
end
if type(lastCommitted) ~= "table"
or lastCommitted.battleId ~= authority.battleId
or lastCommitted.turnId ~= authority.lastCommittedTurnId
or type(lastCommitted.afterState) ~= "table"
or not deepEqual(lastCommitted.afterState, authority) then
return nil, {
makeError(
"terminal_receipt_mismatch",
"$.state.lastCommittedPending",
"마지막 확정 턴 영수증이 종료 권위 상태와 정확히 일치하지 않습니다."
),
}
end
local presented, presentationErrors = callModule(
"turnPresentation",
"build",
lastCommitted,
staticData
)
if presentationErrors then return nil, presentationErrors end
if type(presented.lastTurn) ~= "table" or presented.lastTurn.available ~= true then
return nil, {
makeError(
"missing_terminal_presentation",
"$.runtime.turnPresentation.lastTurn",
"종료 턴의 공개 결과를 검증하지 못했습니다."
),
}
end
local events = type(lastCommitted.turnResult) == "table"
and type(lastCommitted.turnResult.publicResult) == "table"
and lastCommitted.turnResult.publicResult.events
or nil
if type(events) ~= "table" then
return nil, {
makeError(
"missing_terminal_events",
"$.state.lastCommittedPending.turnResult.publicResult.events",
"종료 턴의 공개 사건 배열이 없습니다."
),
}
end
local outcomePayload
local outcomeCount = 0
local sessionStatus
local sessionCount = 0
for _, event in ipairs(events) do
if type(event) == "table" and event.type == "outcome" then
outcomeCount = outcomeCount + 1
outcomePayload = event.payload
elseif type(event) == "table" and event.type == "session_ended" then
sessionCount = sessionCount + 1
sessionStatus = type(event.payload) == "table" and event.payload.status or nil
end
end
if outcomeCount ~= 1
or sessionCount ~= 1
or type(outcomePayload) ~= "table"
or outcomePayload.status ~= authority.status
or sessionStatus ~= authority.status
or (outcomePayload.reasonCode ~= "card_checkpoint"
and outcomePayload.reasonCode ~= "turn_end_checkpoint"
and outcomePayload.reasonCode ~= "turn_limit")
or outcomePayload.stealth ~= authority.player.stealth
or outcomePayload.resistance ~= authority.character.resistance then
return nil, {
makeError(
"invalid_terminal_events",
"$.state.lastCommittedPending.turnResult.publicResult.events",
"종료 공개 사건이 권위 상태의 승패와 최종 수치에 일치하지 않습니다."
),
}
end
if outcomePayload.reasonCode == "turn_limit" and authority.status ~= "defeat" then
return nil, {
makeError(
"invalid_terminal_reason",
"$.state.lastCommittedPending.turnResult.publicResult.events",
"턴 제한 종료는 패배 결과여야 합니다."
),
}
end
local transitCopy, transitError = cloneJson(authority.transit, "$.summary.transit")
if transitError then return nil, { transitError } end
return {
battleId = authority.battleId,
turnId = authority.lastCommittedTurnId,
characterId = authority.character.characterId,
status = authority.status,
reasonCode = outcomePayload.reasonCode,
turnNumber = authority.turnNumber,
turnLimit = authority.turnLimit,
finalStealth = authority.player.stealth,
finalResistance = authority.character.resistance,
transit = transitCopy,
}, nil
end
local function buildAftermathInstruction(authority, aftermath)
local finalTurn = aftermath.completedTurnNumber + 1 == authority.turnLimit
local lines = {
"[함락 후 자유행동]",
"전투 승리는 이미 확정되었다. 카드, 저항, 은폐, 무드, 전투 판정이나 보상을 언급하지 마라.",
"현재 장면은 " .. authority.sceneContext.weekday .. " "
.. authority.sceneContext.time .. "이며 날씨는 "
.. authority.sceneContext.weather
.. "이다. 열차의 혼잡도, 승객의 분위기, 복장과 우산 같은 배경에 자연스럽게 반영하되 게임 효과나 수치 규칙으로 해석하지 마라.",
"사용자가 방금 입력한 행위를 그대로 존중하여 캐릭터의 반응과 열차 안의 장면을 자연스럽게 이어서 묘사하라.",
"사용자가 입력하지 않은 추가 행동, 대사, 생각을 플레이어에게 부여하지 마라.",
}
if finalTurn then
lines[#lines + 1] = "이번 자유행동은 마지막이다. 응답의 끝에서 반드시 열차가 목적지 역에 도착하고 캐릭터가 황급히 열차에서 내리는 장면으로 마무리하라."
end
return {
role = "system",
content = table.concat(lines, "\n"),
}
end
local function fingerprintAftermathResponseIdentity(response, path)
if type(response) ~= "table" or response.role ~= "char" then
return nil, makeError("invalid_aftermath_response_identity", path, "자유행동 응답 식별자를 만들 수 없습니다.")
end
return fingerprintChatRange({ {
role = response.role,
time = response.time or 0,
} }, 1, 1, path)
end
local function buildAftermathCommitted(chat, turnNumber, responseLuaIndex)
local response = isInteger(responseLuaIndex, 1) and chat[responseLuaIndex] or nil
if not isInteger(responseLuaIndex, 1)
or type(response) ~= "table"
or response.role ~= "char" then
return nil, {
makeError("invalid_aftermath_committed_response", "$.chat", "확정할 자유행동 캐릭터 응답 위치가 올바르지 않습니다."),
}
end
local fingerprint, fingerprintError = fingerprintChatRange(
chat,
1,
responseLuaIndex - 1,
"$.aftermath.lastCommitted.prefix"
)
if fingerprintError then return nil, { fingerprintError } end
local responseIdentity, identityError = fingerprintAftermathResponseIdentity(
response,
"$.aftermath.lastCommitted.responseIdentity"
)
if identityError then return nil, { identityError } end
return {
schemaVersion = 1,
kind = "battleAftermathCommitted",
turnNumber = turnNumber,
responseLuaIndex = responseLuaIndex,
prefixFingerprint = fingerprint,
responseIdentityFingerprint = responseIdentity,
}, nil
end
local function validateAftermathCommittedChat(aftermath, chat)
local receipt = aftermath.lastCommitted
if receipt == nil then return nil end
if #chat < receipt.responseLuaIndex then
return {
makeError("aftermath_committed_chat_missing", "$.chat", "이미 확정된 자유행동 장면이 대화에서 삭제되었습니다."),
}
end
local fingerprint, fingerprintError = fingerprintChatRange(
chat,
1,
receipt.responseLuaIndex - 1,
"$.aftermath.lastCommitted.currentPrefix"
)
if fingerprintError then return { fingerprintError } end
local response = chat[receipt.responseLuaIndex]
if type(response) ~= "table" or response.role ~= "char" then
return {
makeError("aftermath_committed_chat_changed", "$.chat", "이미 확정된 자유행동 장면이 변경되었습니다."),
}
end
local responseIdentity, identityError = fingerprintAftermathResponseIdentity(
response,
"$.aftermath.lastCommitted.currentResponseIdentity"
)
if identityError then return { identityError } end
if not fingerprintsEqual(fingerprint, receipt.prefixFingerprint)
or not fingerprintsEqual(responseIdentity, receipt.responseIdentityFingerprint) then
return {
makeError("aftermath_committed_chat_changed", "$.chat", "이미 확정된 자유행동 장면이 변경되었습니다."),
}
end
return nil
end
local function migrateLegacyAftermath(authority, aftermath)
if aftermath.schemaVersion ~= 1 or aftermath.phase == "complete" then
return aftermath, nil
end
local chat, chatErrors = readChat()
if chatErrors then return nil, chatErrors end
local binding, bindingErrors = readStored(KEYS.activeRequest, true)
if bindingErrors then return nil, bindingErrors end
local bindingValidationErrors = validateBinding(binding, nil)
if #bindingValidationErrors > 0 then return nil, bindingValidationErrors end
if binding.phase ~= "committed" or binding.battleId ~= aftermath.battleId then
return nil, {
makeError("legacy_aftermath_migration_unsafe", "$.activeRequest", "기존 자유행동의 기준 전투 응답을 안전하게 찾을 수 없습니다."),
}
end
local prefixErrors = validateAnchorPrefix(binding, chat)
if prefixErrors then return nil, prefixErrors end
local responseLuaIndex = binding.chatAnchor.responseIndex + 1
+ (aftermath.completedTurnNumber - aftermath.victoryTurnNumber) * 2
if aftermath.phase == "ready" then
local suffixCount = #chat - responseLuaIndex
local first = chat[responseLuaIndex + 1]
local firstIsInput = type(first) == "table"
and first.role == "user"
and not isExactFiller(first)
if suffixCount < 0
or (suffixCount == 1
and not (firstIsInput or isExactFiller(first)))
or suffixCount > 1 then
return nil, {
makeError("legacy_aftermath_migration_unsafe", "$.chat", "기존 자유행동 대화 끝을 안전하게 이전할 수 없습니다."),
}
end
elseif aftermath.phase == "settling" then
if type(aftermath.request) ~= "table"
or aftermath.request.userLuaIndex + 1 ~= responseLuaIndex then
return nil, {
makeError("legacy_aftermath_migration_unsafe", "$.aftermath.request", "기존 자유행동 정산 응답 위치를 안전하게 이전할 수 없습니다."),
}
end
elseif type(aftermath.request) ~= "table"
or aftermath.request.userLuaIndex ~= responseLuaIndex + 1 then
return nil, {
makeError("legacy_aftermath_migration_unsafe", "$.aftermath.request", "기존 자유행동 요청 위치를 안전하게 이전할 수 없습니다."),
}
end
local receipt, receiptErrors = buildAftermathCommitted(
chat,
aftermath.completedTurnNumber,
responseLuaIndex
)
if receiptErrors then return nil, receiptErrors end
aftermath.schemaVersion = AFTERMATH_SCHEMA_VERSION
aftermath.lastCommitted = receipt
if aftermath.phase == "settling" then
aftermath.request = nil
elseif type(aftermath.request) == "table" then
aftermath.request.attemptNumber = aftermath.request.attemptNumber or 1
end
local validationErrors = validateAftermath(aftermath, authority)
if #validationErrors > 0 then return nil, validationErrors end
local writeErrors = writeStored(KEYS.aftermath, aftermath)
if writeErrors then return nil, writeErrors end
return aftermath, nil
end
local function validateAftermathRequestChat(aftermath, chat, requireResponse, allowTrailingFillers)
local committedErrors = validateAftermathCommittedChat(aftermath, chat)
if committedErrors then return nil, committedErrors end
local request = aftermath.request
local user = type(request) == "table" and chat[request.userLuaIndex] or nil
if type(user) ~= "table" or user.role ~= "user" then
return nil, {
makeError("aftermath_user_missing", "$.chat", "자유행동 요청에 연결된 사용자 입력이 없습니다."),
}
end
if type(aftermath.lastCommitted) == "table"
and request.userLuaIndex ~= aftermath.lastCommitted.responseLuaIndex + 1 then
return nil, {
makeError("aftermath_chat_topology_mismatch", "$.chat", "확정 장면과 현재 자유행동 입력 사이에 예상하지 않은 메시지가 있습니다."),
}
end
local fingerprint, fingerprintError = fingerprintChatRange(
chat,
request.userLuaIndex,
1,
"$.aftermath.request.user"
)
if fingerprintError then return nil, { fingerprintError } end
if not fingerprintsEqual(fingerprint, request.userFingerprint) then
return nil, {
makeError("aftermath_user_changed", "$.chat", "생성 중인 자유행동 사용자 입력이 변경되었습니다."),
}
end
local response = chat[request.userLuaIndex + 1]
if allowTrailingFillers and isExactFiller(response) then
response = nil
end
if response ~= nil and (type(response) ~= "table" or response.role ~= "char") then
return nil, {
makeError("aftermath_response_role_mismatch", "$.chat", "자유행동 응답 위치에 캐릭터 메시지가 아닌 항목이 있습니다."),
}
end
local expectedLength = request.userLuaIndex + (response ~= nil and 1 or 0)
if #chat ~= expectedLength then
if #chat < expectedLength or not allowTrailingFillers then
return nil, {
makeError("aftermath_chat_topology_mismatch", "$.chat", "자유행동 요청 뒤에 소유권을 확인할 수 없는 메시지가 있습니다."),
}
end
for index = expectedLength + 1, #chat do
if not isExactFiller(chat[index]) then
return nil, {
makeError("aftermath_chat_topology_mismatch", "$.chat", "자유행동 요청 뒤에 소유권을 확인할 수 없는 메시지가 있습니다."),
}
end
end
end
if requireResponse and response == nil then
return nil, {
makeError("aftermath_output_missing", "$.chat", "확정할 자유행동 응답이 없습니다."),
}
end
return response, nil
end
local function validateAftermathOutput(response)
if type(response.data) ~= "string" then
return {
makeError("invalid_aftermath_output", "$.chat", "자유행동 캐릭터 응답이 문자열이 아닙니다."),
}
end
if string.match(response.data, "%S") == nil then
return {
makeError("aftermath_output_blank", "$.chat", "빈 자유행동 응답은 확정할 수 없습니다."),
}
end
if isRisuErrorResponse(response) then
return {
makeError("aftermath_output_error", "$.chat", "LLM 오류 응답은 자유행동 장면으로 확정할 수 없습니다."),
}
end
return nil
end
local function observeAftermathOutput(authority, aftermath, chat)
local request = aftermath.request
local responseLuaIndex = request.userLuaIndex + 1
local fingerprint, fingerprintError = fingerprintAftermathResponseIdentity(
chat[responseLuaIndex],
"$.aftermath.request.outputObserved.response"
)
if fingerprintError then return { fingerprintError } end
local observed = request.outputObserved
if observed ~= nil then
if observed.attemptNumber ~= (request.attemptNumber or 1)
or observed.responseLuaIndex ~= responseLuaIndex
or not fingerprintsEqual(observed.responseFingerprint, fingerprint) then
return {
makeError("aftermath_observed_output_mismatch", "$.chat", "현재 자유행동 응답이 저장된 출력 관측 영수증과 다릅니다."),
}
end
return nil
end
request.attemptNumber = request.attemptNumber or 1
request.outputObserved = {
schemaVersion = 1,
kind = "battleAftermathOutputObserved",
attemptNumber = request.attemptNumber,
responseLuaIndex = responseLuaIndex,
responseFingerprint = fingerprint,
}
aftermath.schemaVersion = AFTERMATH_SCHEMA_VERSION
local validationErrors = validateAftermath(aftermath, authority)
if #validationErrors > 0 then return validationErrors end
return writeStored(KEYS.aftermath, aftermath)
end
local function beginAftermathRecoveringCleanup(authority, aftermath, chat)
local request = aftermath.request
local responseLuaIndex = request.userLuaIndex + 1
local fingerprint, fingerprintError = fingerprintChatRange(
chat,
responseLuaIndex,
1,
"$.aftermath.retry.response"
)
if fingerprintError then return nil, { fingerprintError } end
request.outputObserved = nil
request.recoveringCleanup = {
schemaVersion = 1,
kind = "battleAftermathRecoveringCleanup",
attemptNumber = request.attemptNumber or 1,
responseLuaIndex = responseLuaIndex,
responseFingerprint = fingerprint,
}
aftermath.schemaVersion = AFTERMATH_SCHEMA_VERSION
local validationErrors = validateAftermath(aftermath, authority)
if #validationErrors > 0 then return nil, validationErrors end
local writeErrors = writeStored(KEYS.aftermath, aftermath)
if writeErrors then return nil, writeErrors end
return request.recoveringCleanup, nil
end
local function settleAftermath(authority, aftermath, staticData)
local lastCommitted, lastErrors = readStored(KEYS.lastCommittedPending, true)
if lastErrors then return failure(lastErrors) end
local summary, summaryErrors = buildTerminalSummary(authority, lastCommitted, staticData)
if summaryErrors then return failure(summaryErrors) end
local _, battleLogErrors = publishTerminalBattleLog(authority, staticData)
if battleLogErrors then return failure(battleLogErrors) end
local settled, settlementErrors = callModule(
"gameSetupController",
"completeBattle",
summary
)
if settlementErrors then return failure(settlementErrors) end
if type(settled.view) ~= "table" or type(settled.state) ~= "table" then
return failure({
makeError("invalid_settlement_result", "$.runtime.gameSetupController.completeBattle", "자유행동 종료 정산이 진행 상태와 보상 View를 반환하지 않았습니다."),
})
end
aftermath.schemaVersion = AFTERMATH_SCHEMA_VERSION
aftermath.phase = "complete"
aftermath.request = nil
local writeErrors = writeStored(KEYS.aftermath, aftermath)
if writeErrors then return failure(writeErrors) end
return success({
generationReady = false,
outputCommitted = true,
uiTargetIndex = aftermath.lastCommitted.responseLuaIndex - 1,
aftermathComplete = true,
status = authority.status,
view = settled.view,
progressionState = settled.state,
})
end
local function commitAftermathOutput(authority, aftermath, staticData)
if aftermath.phase == "settling" then
return settleAftermath(authority, aftermath, staticData)
end
if aftermath.phase ~= "requestInjected" then
return failure({
makeError("aftermath_request_not_injected", "$.aftermath.phase", "장면 지시가 주입되지 않은 자유행동 출력은 확정할 수 없습니다."),
})
end
if aftermath.request.recoveringCleanup ~= nil then
return failure({
makeError("aftermath_cleanup_in_progress", "$.aftermath.request.recoveringCleanup", "미확정 자유행동 응답 삭제 복구 중에는 출력을 확정할 수 없습니다."),
})
end
local chat, chatErrors = readChat()
if chatErrors then return failure(chatErrors) end
local response, topologyErrors = validateAftermathRequestChat(aftermath, chat, true)
if topologyErrors then return failure(topologyErrors) end
local outputErrors = validateAftermathOutput(response)
if outputErrors then return failure(outputErrors) end
local observedErrors = observeAftermathOutput(authority, aftermath, chat)
if observedErrors then return failure(observedErrors) end
local completedTurnNumber = aftermath.completedTurnNumber + 1
local committedReceipt, receiptErrors = buildAftermathCommitted(
chat,
completedTurnNumber,
aftermath.request.userLuaIndex + 1
)
if receiptErrors then return failure(receiptErrors) end
aftermath.schemaVersion = AFTERMATH_SCHEMA_VERSION
aftermath.completedTurnNumber = completedTurnNumber
aftermath.lastCommitted = committedReceipt
aftermath.request = nil
aftermath.phase = completedTurnNumber == authority.turnLimit and "settling" or "ready"
local validationErrors = validateAftermath(aftermath, authority)
if #validationErrors > 0 then return failure(validationErrors) end
local writeErrors = writeStored(KEYS.aftermath, aftermath)
if writeErrors then return failure(writeErrors) end
if aftermath.phase == "settling" then
return settleAftermath(authority, aftermath, staticData)
end
local published, publishErrors = publishCurrentViewInternal(staticData, true)
if publishErrors then return failure(publishErrors) end
return success({
generationReady = false,
outputCommitted = true,
uiTargetIndex = aftermath.lastCommitted.responseLuaIndex - 1,
aftermathComplete = false,
status = authority.status,
view = published.view,
})
end
local function skipAftermath(expectedBattleId, expectedViewTurnId)
local staticData, staticErrors = loadStaticData()
if staticErrors then return failure(staticErrors) end
local authority, authorityErrors = readStored(KEYS.authority, true)
if authorityErrors then return failure(authorityErrors) end
if expectedBattleId ~= authority.battleId then
return failure({
makeError("stale_aftermath_skip", "$.expectedBattleId", "현재 전투와 다른 화면의 도착 건너뛰기 요청입니다."),
})
end
local aftermath, aftermathReadErrors = readStored(KEYS.aftermath, false)
if aftermathReadErrors then return failure(aftermathReadErrors) end
if aftermath == nil then
return failure({
makeError("aftermath_skip_unavailable", "$.aftermath", "건너뛸 승리 후 자유행동이 없습니다."),
})
end
local aftermathErrors = validateAftermath(aftermath, authority)
if #aftermathErrors > 0 then return failure(aftermathErrors) end
local migrated, migrationErrors = migrateLegacyAftermath(authority, aftermath)
if migrationErrors then return failure(migrationErrors) end
aftermath = migrated
if aftermath.phase == "settling" then
local settled = settleAftermath(authority, aftermath, staticData)
if type(settled) == "table" and settled.ok == true then
settled.skipped = true
settled.reused = true
end
return settled
end
if aftermath.phase == "complete" then
return success({
generationReady = false,
outputCommitted = true,
uiTargetIndex = aftermath.lastCommitted.responseLuaIndex - 1,
aftermathComplete = true,
skipped = true,
reused = true,
status = authority.status,
})
end
local currentViewTurnId = string.format(
"%s-aftermath-%03d",
authority.battleId,
aftermath.completedTurnNumber
)
if expectedViewTurnId ~= currentViewTurnId then
return failure({
makeError("stale_aftermath_skip", "$.expectedViewTurnId", "현재 승리 후 화면과 다른 도착 건너뛰기 요청입니다."),
})
end
if aftermath.phase ~= "ready" then
return failure({
makeError("aftermath_skip_in_flight", "$.aftermath.phase", "자유행동 장면을 생성하는 중에는 남은 턴을 건너뛸 수 없습니다."),
})
end
local chat, chatErrors = readChat()
if chatErrors then return failure(chatErrors) end
local committedErrors = validateAftermathCommittedChat(aftermath, chat)
if committedErrors then return failure(committedErrors) end
local responseLuaIndex = aftermath.lastCommitted.responseLuaIndex
if #chat ~= responseLuaIndex then
return failure({
makeError("aftermath_skip_chat_not_clean", "$.chat", "확정 장면 뒤에 자유행동 입력 또는 예상하지 않은 메시지가 있어 건너뛸 수 없습니다."),
})
end
local previousCompletedTurnNumber = aftermath.completedTurnNumber
local committedReceipt, receiptErrors = buildAftermathCommitted(
chat,
authority.turnLimit,
responseLuaIndex
)
if receiptErrors then return failure(receiptErrors) end
aftermath.schemaVersion = AFTERMATH_SCHEMA_VERSION
aftermath.completedTurnNumber = authority.turnLimit
aftermath.lastCommitted = committedReceipt
aftermath.request = nil
aftermath.phase = "settling"
local validationErrors = validateAftermath(aftermath, authority)
if #validationErrors > 0 then return failure(validationErrors) end
local writeErrors = writeStored(KEYS.aftermath, aftermath)
if writeErrors then return failure(writeErrors) end
local settled = settleAftermath(authority, aftermath, staticData)
if type(settled) == "table" and settled.ok == true then
settled.skipped = true
settled.reused = false
settled.skippedTurnCount = authority.turnLimit - previousCompletedTurnNumber
end
return settled
end
local function prepareAftermathGeneration(authority, aftermath, staticData)
if aftermath.phase == "complete" then
return success({
generationReady = false,
outputCommitted = false,
aftermathComplete = true,
})
end
if aftermath.phase == "settling" then
local chat, chatErrors = readChat()
if chatErrors then return failure(chatErrors) end
local _, removedFillers, cleanupErrors = removeTrailingSayNothing(chat, 0)
if cleanupErrors then return failure(cleanupErrors) end
local settled = settleAftermath(authority, aftermath, staticData)
if type(settled) == "table" and settled.ok == true then
settled.commitRecovered = true
settled.removedSayNothing = removedFillers > 0
settled.removedSayNothingCount = removedFillers
end
return settled
end
if aftermath.phase == "inFlight" or aftermath.phase == "requestInjected" then
local chat, chatErrors = readChat()
if chatErrors then return failure(chatErrors) end
local response, topologyErrors = validateAftermathRequestChat(aftermath, chat, false, true)
if topologyErrors then return failure(topologyErrors) end
local observed = aftermath.request.outputObserved
if observed ~= nil then
if response == nil then
return failure({
makeError("aftermath_observed_output_missing", "$.chat", "관측된 자유행동 응답이 대화에서 삭제되었습니다."),
})
end
local observedErrors = observeAftermathOutput(authority, aftermath, chat)
if observedErrors then return failure(observedErrors) end
local outputErrors = validateAftermathOutput(response)
if outputErrors then
if not isExactFiller(chat[#chat]) then return failure(outputErrors) end
local _, beginErrors = beginAftermathRecoveringCleanup(
authority,
aftermath,
chat
)
if beginErrors then return failure(beginErrors) end
else
local cleaned, removedFillers, cleanupErrors = removeTrailingSayNothing(
chat,
aftermath.request.userLuaIndex + 1
)
if cleanupErrors then return failure(cleanupErrors) end
chat = cleaned
local committed = commitAftermathOutput(authority, aftermath, staticData)
if type(committed) ~= "table" or committed.ok ~= true then return committed end
committed.commitRecovered = true
committed.removedSayNothing = removedFillers > 0
committed.removedSayNothingCount = removedFillers
return committed
end
end
local retryCleanup = aftermath.request.recoveringCleanup
local hasRetryFiller = isExactFiller(chat[#chat])
if response ~= nil and not hasRetryFiller and retryCleanup == nil then
return failure({
makeError("aftermath_request_already_in_flight", "$.aftermath.phase", "아직 관측되지 않은 자유행동 응답이 있어 재시도를 시작할 수 없습니다."),
})
end
if retryCleanup == nil and response ~= nil then
local beginErrors
retryCleanup, beginErrors = beginAftermathRecoveringCleanup(
authority,
aftermath,
chat
)
if beginErrors then return failure(beginErrors) end
elseif retryCleanup ~= nil and response ~= nil then
local fingerprint, fingerprintError = fingerprintChatRange(
chat,
retryCleanup.responseLuaIndex,
1,
"$.aftermath.retry.currentResponse"
)
if fingerprintError then return failure({ fingerprintError }) end
if not fingerprintsEqual(fingerprint, retryCleanup.responseFingerprint) then
return failure({
makeError("aftermath_recovering_cleanup_response_mismatch", "$.chat", "삭제 복구 중인 자유행동 응답이 변경되었습니다."),
})
end
end
local cleaned, removedFillers, cleanupErrors = removeTrailingSayNothing(
chat,
aftermath.request.userLuaIndex
)
if cleanupErrors then return failure(cleanupErrors) end
chat = cleaned
local removedResponse = retryCleanup ~= nil
response = chat[aftermath.request.userLuaIndex + 1]
if retryCleanup ~= nil and response ~= nil then
local afterRemoval, removeErrors = removeRecoveryResponse(
chat,
retryCleanup.responseLuaIndex,
retryCleanup.responseFingerprint
)
if removeErrors then return failure(removeErrors) end
chat = afterRemoval
end
local _, finalTopologyErrors = validateAftermathRequestChat(aftermath, chat, false)
if finalTopologyErrors then return failure(finalTopologyErrors) end
aftermath.schemaVersion = AFTERMATH_SCHEMA_VERSION
aftermath.phase = "inFlight"
aftermath.request.attemptNumber = (aftermath.request.attemptNumber or 1) + 1
aftermath.request.outputObserved = nil
aftermath.request.recoveringCleanup = nil
local validationErrors = validateAftermath(aftermath, authority)
if #validationErrors > 0 then return failure(validationErrors) end
local writeErrors = writeStored(KEYS.aftermath, aftermath)
if writeErrors then return failure(writeErrors) end
return success({
generationReady = true,
aftermath = true,
reused = true,
zeroOutputRetry = not removedResponse,
commitRecovered = false,
turnNumber = aftermath.request.turnNumber,
attemptNumber = aftermath.request.attemptNumber,
removedSayNothing = removedFillers > 0,
removedSayNothingCount = removedFillers,
removedUncommittedOutput = removedResponse,
})
end
local chat, chatErrors = readChat()
if chatErrors then return failure(chatErrors) end
local committedErrors = validateAftermathCommittedChat(aftermath, chat)
if committedErrors then return failure(committedErrors) end
local removedFillers = 0
if isExactFiller(chat[#chat]) then
local cleaned, cleanupCount, cleanupErrors = removeTrailingSayNothing(
chat,
aftermath.lastCommitted.responseLuaIndex
)
if cleanupErrors then return failure(cleanupErrors) end
chat = cleaned
removedFillers = cleanupCount
end
if #chat == aftermath.lastCommitted.responseLuaIndex then
local published, publishErrors = publishCurrentViewInternal(staticData, true)
if publishErrors then return failure(publishErrors) end
return success({
generationReady = false,
outputCommitted = false,
aftermathComplete = false,
uiTargetRequired = true,
uiTargetIndex = aftermath.lastCommitted.responseLuaIndex - 1,
commitRecovered = false,
removedSayNothing = removedFillers > 0,
removedSayNothingCount = removedFillers,
view = published.view,
})
end
if removedFillers > 0 then
return failure({
makeError("aftermath_automatic_continue_rejected", "$.chat", "빈 전송이나 Continue는 자유행동으로 처리하지 않습니다."),
})
end
local userLuaIndex = #chat
local user = chat[userLuaIndex]
if type(user) ~= "table" or user.role ~= "user" then
return failure({
makeError("missing_aftermath_input", "$.chat", "함락 후에는 원하는 행위를 입력한 뒤 전송해야 합니다."),
})
end
if type(aftermath.lastCommitted) == "table"
and userLuaIndex ~= aftermath.lastCommitted.responseLuaIndex + 1 then
return failure({
makeError("aftermath_chat_topology_mismatch", "$.chat", "확정 장면 뒤에는 하나의 새 자유행동 입력만 올 수 있습니다."),
})
end
local fingerprint, fingerprintError = fingerprintChatRange(
chat,
userLuaIndex,
1,
"$.aftermath.request.user"
)
if fingerprintError then return failure({ fingerprintError }) end
aftermath.schemaVersion = AFTERMATH_SCHEMA_VERSION
aftermath.phase = "inFlight"
aftermath.request = {
turnNumber = aftermath.completedTurnNumber + 1,
userLuaIndex = userLuaIndex,
userFingerprint = fingerprint,
attemptNumber = 1,
}
local validationErrors = validateAftermath(aftermath, authority)
if #validationErrors > 0 then return failure(validationErrors) end
local writeErrors = writeStored(KEYS.aftermath, aftermath)
if writeErrors then return failure(writeErrors) end
return success({
generationReady = true,
aftermath = true,
reused = false,
turnNumber = aftermath.request.turnNumber,
attemptNumber = aftermath.request.attemptNumber,
})
end
local function injectAftermathRequest(promptArray, authority, aftermath)
if aftermath.phase ~= "inFlight" and aftermath.phase ~= "requestInjected" then
return failure({
makeError("aftermath_request_not_in_flight", "$.aftermath.phase", "생성 중인 자유행동 요청이 없습니다."),
})
end
local promptCopy, cloneError = cloneJson(promptArray, "$.promptArray")
if cloneError then return failure({ cloneError }) end
if type(promptCopy) ~= "table" then
return failure({
makeError("invalid_prompt_array", "$.promptArray", "editRequest 값은 메시지 배열이어야 합니다."),
})
end
local promptCount = 0
for key, message in pairs(promptCopy) do
if type(key) ~= "number" or not isInteger(key, 1) or type(message) ~= "table" then
return failure({
makeError("invalid_prompt_array", "$.promptArray", "editRequest 값은 1부터 이어지는 메시지 객체 배열이어야 합니다."),
})
end
promptCount = promptCount + 1
end
if promptCount ~= #promptCopy then
return failure({
makeError("invalid_prompt_array", "$.promptArray", "editRequest 메시지 배열에 빈 인덱스가 있습니다."),
})
end
local chat, chatErrors = readChat()
if chatErrors then return failure(chatErrors) end
local response, topologyErrors = validateAftermathRequestChat(aftermath, chat, false)
if topologyErrors then return failure(topologyErrors) end
if response ~= nil
or aftermath.request.outputObserved ~= nil
or aftermath.request.recoveringCleanup ~= nil then
return failure({
makeError("aftermath_request_not_clean", "$.chat", "응답이 시작되거나 관측된 자유행동 요청에는 프롬프트를 다시 주입할 수 없습니다."),
})
end
local instruction = buildAftermathInstruction(authority, aftermath)
local normalized = {}
local removed = 0
for _, message in ipairs(promptCopy) do
if deepEqual(message, instruction) then
removed = removed + 1
else
normalized[#normalized + 1] = message
end
end
normalized[#normalized + 1] = instruction
if aftermath.phase == "inFlight" then
aftermath.phase = "requestInjected"
local validationErrors = validateAftermath(aftermath, authority)
if #validationErrors > 0 then return failure(validationErrors) end
local writeErrors = writeStored(KEYS.aftermath, aftermath)
if writeErrors then return failure(writeErrors) end
end
return success({
promptArray = normalized,
injected = removed == 0,
deduplicated = removed > 1,
aftermath = true,
finalTurn = aftermath.completedTurnNumber + 1 == authority.turnLimit,
requestPhase = aftermath.phase,
attemptNumber = aftermath.request.attemptNumber,
})
end
local function getTerminalSummary()
local staticData, staticErrors = loadStaticData()
if staticErrors then return failure(staticErrors) end
local current, currentErrors = readRuntimeBundle()
if currentErrors then return failure(currentErrors) end
if current.authority == nil then
return success({
terminal = false,
hasBattle = false,
settlementAvailable = false,
})
end
if current.authority.status == "active" then
return success({
terminal = false,
hasBattle = true,
settlementAvailable = false,
battleId = current.authority.battleId,
status = current.authority.status,
})
end
if current.aftermath ~= nil then
local aftermathErrors = validateAftermath(current.aftermath, current.authority)
if #aftermathErrors > 0 then return failure(aftermathErrors) end
if current.aftermath.phase ~= "complete" then
return success({
terminal = true,
hasBattle = true,
settlementAvailable = false,
battleId = current.authority.battleId,
status = current.authority.status,
})
end
elseif current.authority.status == "victory"
and current.authority.turnNumber < current.authority.turnLimit then
return success({
terminal = true,
hasBattle = true,
settlementAvailable = false,
battleId = current.authority.battleId,
status = current.authority.status,
})
end
if current.lastCommittedPending == nil
and current.draft == nil
and current.pending == nil
and current.activeRequest == nil
and current.submission == nil then
-- startFromRun은 종료 영수증을 확인한 뒤 보조 상태를 먼저
-- 비운다. 그 직후 중단된 경우에도 다음 호출이 run의 저장된
-- 정산 영수증으로 retirement를 재개할 수 있게 identity만
-- 반환한다.
return success({
terminal = true,
hasBattle = true,
settlementAvailable = false,
battleId = current.authority.battleId,
status = current.authority.status,
})
end
local summary, summaryErrors = buildTerminalSummary(
current.authority,
current.lastCommittedPending,
staticData
)
if summaryErrors then return failure(summaryErrors) end
return success({
terminal = true,
hasBattle = true,
settlementAvailable = true,
battleId = current.authority.battleId,
status = current.authority.status,
summary = summary,
})
end
local function writeInitialRuntime(expected)
for _, name in ipairs({
"authority",
"draft",
"pending",
"lastCommittedPending",
"activeRequest",
"submission",
"aftermath",
}) do
local writeErrors = writeStored(KEYS[name], expected[name])
if writeErrors then
return writeErrors
end
end
return nil
end
local function validateExistingSetupBinding(authority, spec, staticData)
local errors = {}
local function conflict(path, message)
errors[#errors + 1] = makeError("battle_setup_conflict", path, message)
end
if type(authority.rng) ~= "table" or authority.rng.seed ~= spec.seed then
conflict("$.state.authority.rng.seed", "기존 전투의 RNG seed가 setup 전투 사양과 다릅니다.")
end
local journey, journeyErrors = callModule(
"subwayJourney",
"build",
spec.seed,
staticData,
staticData.characters[spec.characterId].battle.turnLimit
)
if journeyErrors then
for _, item in ipairs(journeyErrors) do
errors[#errors + 1] = item
end
elseif type(journey) ~= "table"
or authority.turnLimit ~= journey.turnLimit
or not deepEqual(authority.transit, journey.transit)
or not deepEqual(authority.sceneContext, journey.sceneContext) then
conflict("$.state.authority", "기존 전투의 제한 턴, 지하철 여정 또는 배경 정보가 setup seed에서 결정한 값과 다릅니다.")
end
if type(authority.character) ~= "table"
or authority.character.characterId ~= spec.characterId then
conflict("$.state.authority.character.characterId", "기존 전투의 캐릭터가 setup 선택 결과와 다릅니다.")
end
local initialPlayerCards = {}
for _, instance in ipairs(type(authority.cardInstances) == "table" and authority.cardInstances or {}) do
if type(instance) == "table" and type(instance.instanceId) == "string" then
local rawIndex = string.match(instance.instanceId, "^player%-(%d%d%d)$")
local index = rawIndex ~= nil and tonumber(rawIndex) or nil
if index ~= nil and index >= 1 and index <= #spec.playerCardIds then
if initialPlayerCards[index] ~= nil then
conflict(
"$.state.authority.cardInstances",
"기존 전투에 같은 초기 플레이어 instanceId가 중복되었습니다."
)
else
initialPlayerCards[index] = {
cardId = instance.cardId,
owner = instance.owner,
}
end
end
end
end
for index, expectedCardId in ipairs(spec.playerCardIds) do
local actual = initialPlayerCards[index]
local instanceId = string.format("player-%03d", index)
if type(actual) ~= "table"
or actual.owner ~= "player"
or actual.cardId ~= expectedCardId then
conflict(
"$.state.authority.cardInstances[" .. string.format("%q", instanceId) .. "]",
"기존 전투의 초기 플레이어 덱이 setup에서 선택한 카드 순서와 다릅니다."
)
end
end
if #errors > 0 then
return errors
end
return nil
end
local function startFromSetup(setupState, canonicalInput)
local staticData, staticErrors = loadStaticData()
if staticErrors then
return failure(staticErrors)
end
local canonicalSetup, spec, setupErrors
if canonicalInput == true then
canonicalSetup, spec, setupErrors = inspectCanonicalBattleReadySetup(setupState)
else
canonicalSetup, spec, setupErrors = validateBattleReadySetup(setupState, staticData)
end
if setupErrors then
return failure(setupErrors)
end
local current, currentErrors = readRuntimeBundle()
if currentErrors then
return failure(currentErrors)
end
if current.authority ~= nil then
if type(current.authority) ~= "table"
or current.authority.battleId ~= spec.battleId then
return failure({
makeError(
"battle_runtime_conflict",
"$.state.authority.battleId",
"다른 전투의 권위 상태가 있어 setup 전투로 덮어쓸 수 없습니다."
),
})
end
local bindingErrors = validateExistingSetupBinding(current.authority, spec, staticData)
if bindingErrors then
return failure(bindingErrors)
end
-- 정상적인 같은 전투는 첫 턴 중이라도 draft 선택, pending,
-- generation binding이 진행됐을 수 있다. 이 상태를 다시 만들지
-- 않고 현재 권위 경계에서 View/UI만 재게시한다.
local looksLikeInitialPartial = current.authority.status == "active"
and current.draft == nil
and current.pending == nil
and current.lastCommittedPending == nil
and current.activeRequest == nil
and current.submission == nil
and current.aftermath == nil
if not looksLikeInitialPartial then
local published, publishErrors = publishCurrentViewInternal(staticData, true)
if publishErrors then
return failure(publishErrors)
end
return success({
applied = false,
reused = true,
recovered = false,
battleId = current.authority.battleId,
turnNumber = current.authority.turnNumber,
setupId = canonicalSetup.setupId,
view = published.view,
})
end
end
-- 새 시작, 또는 authority까지만 저장된 안전한 부분 write를 같은
-- 결정적 초기 상태와 exact 비교한 뒤에만 완성한다.
local expected, expectedErrors = buildInitialBattleFromSetup(spec, staticData)
if expectedErrors then
return failure(expectedErrors)
end
for _, name in ipairs({
"authority",
"draft",
"pending",
"lastCommittedPending",
"activeRequest",
"submission",
"aftermath",
}) do
local existing = current[name]
if existing ~= nil and not deepEqual(existing, expected[name]) then
return failure({
makeError(
"unsafe_partial_battle_runtime",
"$.state[" .. string.format("%q", KEYS[name]) .. "]",
"기존 부분 전투 상태가 setup에서 재생한 초기 상태와 일치하지 않아 복구할 수 없습니다."
),
})
end
end
local recovered = current.authority ~= nil or current.draft ~= nil
local writeErrors = writeInitialRuntime(expected)
if writeErrors then
return failure(writeErrors)
end
local published, publishErrors = publishCurrentViewInternal(staticData, true)
if publishErrors then
return failure(publishErrors)
end
return success({
applied = true,
reused = false,
recovered = recovered,
battleId = expected.authority.battleId,
turnId = expected.turnId,
turnNumber = expected.authority.turnNumber,
setupId = canonicalSetup.setupId,
view = published.view,
})
end
local function validateBattleReadyRun(runState, setupState, staticData)
local canonicalSetup, _, setupErrors = validateBattleReadySetup(
setupState,
staticData
)
if setupErrors then return nil, nil, nil, setupErrors end
local runCopy, runCopyError = cloneJson(runState, "$.runState")
if runCopyError then return nil, nil, nil, { runCopyError } end
local validated, validationErrors = callModule(
"runProgression",
"validate",
runCopy,
canonicalSetup,
staticData
)
if validationErrors then return nil, nil, nil, validationErrors end
if type(validated.state) ~= "table"
or getmetatable(validated.state) ~= nil
or not deepEqual(runCopy, validated.state) then
return nil, nil, nil, {
makeError(
"run_validation_mismatch",
"$.runState",
"진행 상태가 runProgression.validate의 정규 상태와 정확히 일치하지 않습니다."
),
}
end
if validated.state.phase ~= "battleReady" then
return nil, nil, nil, {
makeError(
"run_not_battle_ready",
"$.runState.phase",
"다음 상대 선택까지 끝난 battleReady 진행 상태만 전투를 시작할 수 있습니다."
),
}
end
local battleSpec = validated.state.battleSpec
if type(battleSpec) ~= "table" or getmetatable(battleSpec) ~= nil then
return nil, nil, nil, {
makeError(
"missing_battle_spec",
"$.runState.battleSpec",
"진행 상태에 다음 전투 사양이 없습니다."
),
}
end
local deckCopy, deckCopyError = cloneJson(
battleSpec.playerCardIds,
"$.runState.battleSpec.playerCardIds"
)
if deckCopyError then return nil, nil, nil, { deckCopyError } end
local normalizedRun, normalizedRunError = cloneJson(
validated.state,
"$.runState"
)
if normalizedRunError then
return nil, nil, nil, { normalizedRunError }
end
return normalizedRun, canonicalSetup, {
battleId = battleSpec.battleId,
seed = battleSpec.seed,
playerCardIds = deckCopy,
characterId = battleSpec.characterId,
}, nil
end
local function inspectCanonicalBattleReadyRun(runState, setupState)
local canonicalSetup, _, setupErrors = inspectCanonicalBattleReadySetup(setupState)
if setupErrors then return nil, nil, nil, setupErrors end
local battleSpec = type(runState) == "table" and runState.battleSpec or nil
if type(runState) ~= "table"
or getmetatable(runState) ~= nil
or runState.schemaVersion ~= SCHEMA_VERSION
or runState.kind ~= "runProgressionV1"
or runState.phase ~= "battleReady"
or runState.setupId ~= canonicalSetup.setupId
or type(runState.sessions) ~= "table"
or type(battleSpec) ~= "table"
or getmetatable(battleSpec) ~= nil
or not isRuntimeId(battleSpec.battleId)
or not isInteger(battleSpec.seed, 1)
or type(battleSpec.playerCardIds) ~= "table"
or type(battleSpec.characterId) ~= "string" then
return nil, nil, nil, {
makeError(
"invalid_canonical_run_contract",
"$.runState",
"내부 canonical battleReady 진행 상태가 필수 postcondition을 만족하지 않습니다."
),
}
end
return runState, canonicalSetup, {
battleId = battleSpec.battleId,
seed = battleSpec.seed,
playerCardIds = battleSpec.playerCardIds,
characterId = battleSpec.characterId,
}, nil
end
local function latestRunSession(runState)
local sessions = type(runState) == "table" and runState.sessions or nil
if type(sessions) ~= "table" then return nil end
return sessions[#sessions]
end
local function summaryMatchesRunSession(summary, session)
return type(summary) == "table"
and type(session) == "table"
and summary.battleId == session.battleId
and summary.turnId == session.turnId
and summary.characterId == session.characterId
and summary.status == session.status
and summary.reasonCode == session.reasonCode
and summary.turnNumber == session.turnNumber
and summary.turnLimit == session.turnLimit
and summary.finalStealth == session.finalStealth
and summary.finalResistance == session.finalResistance
and deepEqual(summary.transit, session.transit)
end
local function validateRetiredBattle(current, runState, staticData)
local authority = current.authority
local session = latestRunSession(runState)
if type(authority) ~= "table"
or (authority.status ~= "victory" and authority.status ~= "defeat")
or type(authority.character) ~= "table"
or type(authority.player) ~= "table"
or type(session) ~= "table"
or authority.battleId ~= session.battleId
or authority.lastCommittedTurnId ~= session.turnId
or authority.character.characterId ~= session.characterId
or authority.status ~= session.status
or authority.turnNumber ~= session.turnNumber
or authority.turnLimit ~= session.turnLimit
or authority.player.stealth ~= session.finalStealth
or authority.character.resistance ~= session.finalResistance
or not deepEqual(authority.transit, session.transit) then
return {
makeError(
"unsettled_battle_runtime",
"$.state.authority",
"기존 종료 전투가 저장된 진행 정산 영수증과 일치하지 않습니다."
),
}
end
if current.aftermath ~= nil then
local aftermathErrors = validateAftermath(current.aftermath, authority)
if #aftermathErrors > 0 then return aftermathErrors end
if current.aftermath.phase ~= "complete" then
return {
makeError(
"aftermath_not_complete",
"$.state.aftermath",
"승리 후 자유행동이 끝나기 전에는 전투를 퇴역시킬 수 없습니다."
),
}
end
end
if current.lastCommittedPending ~= nil then
local summary, summaryErrors = buildTerminalSummary(
authority,
current.lastCommittedPending,
staticData
)
if summaryErrors then return summaryErrors end
if not summaryMatchesRunSession(summary, session) then
return {
makeError(
"settlement_receipt_mismatch",
"$.runState.sessions",
"종료 턴 공개 영수증과 진행 정산 기록이 일치하지 않습니다."
),
}
end
elseif current.draft ~= nil
or current.pending ~= nil
or current.activeRequest ~= nil
or current.submission ~= nil
or current.aftermath ~= nil then
return {
makeError(
"unsafe_retirement_partial",
"$.state",
"종료 영수증이 사라진 부분 정리 상태에 다른 전투 보조 상태가 남아 있습니다."
),
}
end
return nil
end
local function clearRetiredRuntimeAuxiliary()
for _, name in ipairs({
"draft",
"pending",
"activeRequest",
"submission",
"aftermath",
"lastCommittedPending",
}) do
local writeErrors = writeStored(KEYS[name], nil)
if writeErrors then return writeErrors end
end
return nil
end
local function startFromRun(runState, setupState, canonicalInput)
local staticData, staticErrors = loadStaticData()
if staticErrors then return failure(staticErrors) end
local canonicalRun, canonicalSetup, spec, runErrors
if canonicalInput == true then
canonicalRun, canonicalSetup, spec, runErrors =
inspectCanonicalBattleReadyRun(runState, setupState)
else
canonicalRun, canonicalSetup, spec, runErrors =
validateBattleReadyRun(runState, setupState, staticData)
end
if runErrors then return failure(runErrors) end
local current, currentErrors = readRuntimeBundle()
if currentErrors then return failure(currentErrors) end
local retired = false
if current.authority ~= nil and current.authority.battleId ~= spec.battleId then
local retirementErrors = validateRetiredBattle(
current,
canonicalRun,
staticData
)
if retirementErrors then return failure(retirementErrors) end
local clearErrors = clearRetiredRuntimeAuxiliary()
if clearErrors then return failure(clearErrors) end
current.draft = nil
current.pending = nil
current.activeRequest = nil
current.submission = nil
current.lastCommittedPending = nil
current.aftermath = nil
retired = true
end
local expected, expectedErrors = buildInitialBattleFromSetup(spec, staticData)
if expectedErrors then return failure(expectedErrors) end
if current.authority ~= nil and current.authority.battleId == spec.battleId then
local bindingErrors = validateExistingSetupBinding(
current.authority,
spec,
staticData
)
if bindingErrors then return failure(bindingErrors) end
if current.authority.status ~= "active" then
return failure({
makeError(
"battle_already_terminal",
"$.state.authority.status",
"종료된 현재 전투를 먼저 정산해야 다음 상태를 게시할 수 있습니다."
),
})
end
local looksLikeInitialPartial = deepEqual(
current.authority,
expected.authority
)
and current.draft == nil
and current.pending == nil
and current.lastCommittedPending == nil
and current.activeRequest == nil
and current.submission == nil
and current.aftermath == nil
if not looksLikeInitialPartial then
local published, publishErrors = publishCurrentViewInternal(
staticData,
true
)
if publishErrors then return failure(publishErrors) end
return success({
applied = false,
reused = true,
recovered = false,
battleId = current.authority.battleId,
turnNumber = current.authority.turnNumber,
setupId = canonicalSetup.setupId,
view = published.view,
})
end
end
for _, name in ipairs({
"authority",
"draft",
"pending",
"lastCommittedPending",
"activeRequest",
"submission",
"aftermath",
}) do
local existing = current[name]
if name == "authority"
and retired
and type(existing) == "table"
and existing.battleId ~= spec.battleId then
existing = nil
end
if existing ~= nil and not deepEqual(existing, expected[name]) then
return failure({
makeError(
"unsafe_partial_battle_runtime",
"$.state[" .. string.format("%q", KEYS[name]) .. "]",
"기존 부분 전투 상태가 다음 전투 사양에서 재생한 초기 상태와 일치하지 않습니다."
),
})
end
end
local recovered = retired
or current.authority ~= nil
or current.draft ~= nil
or current.pending ~= nil
or current.lastCommittedPending ~= nil
or current.activeRequest ~= nil
or current.submission ~= nil
or current.aftermath ~= nil
local writeErrors = writeInitialRuntime(expected)
if writeErrors then return failure(writeErrors) end
local published, publishErrors = publishCurrentViewInternal(
staticData,
true
)
if publishErrors then return failure(publishErrors) end
return success({
applied = true,
reused = false,
recovered = recovered,
battleId = expected.authority.battleId,
turnId = expected.turnId,
turnNumber = expected.authority.turnNumber,
setupId = canonicalSetup.setupId,
view = published.view,
})
end
local function startVerticalSlice(battleId, seed)
local staticData, staticErrors = loadStaticData()
if staticErrors then
return failure(staticErrors)
end
local bootstrap, bootstrapErrors = callModule(
"battleBootstrap",
"verticalSlice",
{ battleId = battleId, seed = seed },
staticData
)
if bootstrapErrors then
return failure(bootstrapErrors)
end
if bootstrap.referencesValidated ~= true or type(bootstrap.state) ~= "table" then
return failure({
makeError("invalid_bootstrap_result", "$.runtime.battleBootstrap", "bootstrap이 검증된 battleState를 반환하지 않았습니다."),
})
end
local turnId, turnIdErrors = makeTurnId(bootstrap.state)
if turnIdErrors then
return failure(turnIdErrors)
end
local initialized, initializeErrors = callModule(
"turnInitializer",
"prepareTurn",
bootstrap.state,
staticData,
{ turnId = turnId }
)
if initializeErrors then
return failure(initializeErrors)
end
if type(initialized.state) ~= "table" or type(initialized.draft) ~= "table" then
return failure({
makeError("invalid_initial_turn", "$.runtime.turnInitializer", "첫 턴 초기화 상태와 draft가 없습니다."),
})
end
for _, write in ipairs({
{ KEYS.authority, initialized.state },
{ KEYS.draft, initialized.draft },
{ KEYS.pending, nil },
{ KEYS.lastCommittedPending, nil },
{ KEYS.activeRequest, nil },
{ KEYS.submission, nil },
{ KEYS.aftermath, nil },
}) do
local writeErrors = writeStored(write[1], write[2])
if writeErrors then
return failure(writeErrors)
end
end
local published, publishErrors = publishCurrentViewInternal(staticData)
if publishErrors then
return failure(publishErrors)
end
return success({
battleId = initialized.state.battleId,
turnId = turnId,
turnNumber = initialized.state.turnNumber,
view = published.view,
})
end
local function interactCard(interactionAction, instanceId, expectedInteractionToken, choiceId)
if interactionAction ~= "click"
and interactionAction ~= "register"
and interactionAction ~= "cancel"
and interactionAction ~= "choose" then
return failure({
makeError(
"invalid_interaction_action",
"$.interactionAction",
"카드 상호작용 작업은 click, register, cancel, choose 중 하나여야 합니다."
),
})
end
local staticData, staticErrors = loadStaticData()
if staticErrors then
return failure(staticErrors)
end
local authority, authorityErrors = readStored(KEYS.authority, true)
if authorityErrors then
return failure(authorityErrors)
end
local pending, pendingErrors = readStored(KEYS.pending, false)
if pendingErrors then
return failure(pendingErrors)
end
if pending ~= nil then
return failure({
makeError("battle_view_locked", "$.pendingTurn", "출력 대기 중에는 카드 선택을 변경할 수 없습니다."),
})
end
local activeRequest, requestErrors = readStored(KEYS.activeRequest, false)
if requestErrors then
return failure(requestErrors)
end
if activeRequest ~= nil then
local activeErrors = validateBinding(activeRequest, nil)
if #activeErrors > 0 then
return failure(activeErrors)
end
if activeRequest.phase == "preparing"
or activeRequest.phase == "inFlight"
or activeRequest.phase == "requestInjected" then
return failure({
makeError("battle_view_locked", "$.activeRequest.phase", "생성 요청 처리 중에는 카드 선택을 변경할 수 없습니다."),
})
end
end
if not isRuntimeId(instanceId) then
return failure({
makeError("invalid_instance_id", "$.instanceId", "카드 인스턴스 ID가 올바르지 않습니다."),
})
end
if type(expectedInteractionToken) ~= "string" or expectedInteractionToken == "" then
return failure({
makeError("invalid_interaction_token", "$.expectedInteractionToken", "비어 있지 않은 draft interaction token이 필요합니다."),
})
end
if interactionAction == "choose"
and (type(choiceId) ~= "string" or string.match(choiceId, "^[a-z][a-z0-9_]*$") == nil) then
return failure({ makeError("invalid_effect_choice_id", "$.choiceId", "효과 선택지 ID가 올바르지 않습니다.") })
end
local draft, draftErrors = readStored(KEYS.draft, true)
if draftErrors then
return failure(draftErrors)
end
local interacted, interactionErrors = callModule(
"turnDraft",
"applyInteraction",
authority,
staticData,
draft,
{
action = interactionAction,
instanceId = instanceId,
choiceId = choiceId,
expectedInteractionToken = expectedInteractionToken,
}
)
if interactionErrors then
return failure(interactionErrors)
end
if type(interacted.draft) ~= "table"
or type(interacted.interactionToken) ~= "string"
or interacted.interactionToken == ""
or type(interacted.applied) ~= "boolean"
or type(interacted.stale) ~= "boolean"
or interacted.interactionAction ~= interactionAction then
return failure({
makeError(
"invalid_interaction_result",
"$.runtime.turnDraft.applyInteraction",
"검증된 카드 상호작용 결과와 다음 interaction token이 없습니다."
),
})
end
if interacted.stale then
if interacted.applied then
return failure({
makeError(
"invalid_stale_interaction_result",
"$.runtime.turnDraft.applyInteraction",
"stale 카드 상호작용은 전이를 적용할 수 없습니다."
),
})
end
-- risu-btn host가 클릭 message를 자동 remount하므로 수동
-- refresh를 중복하지 않는다.
local published, publishErrors = publishCurrentViewInternal(staticData, true, false, true)
if publishErrors then
return failure(publishErrors)
end
if published.view.interactionToken ~= interacted.interactionToken then
return failure({
makeError("view_interaction_token_mismatch", "$.view.interactionToken", "재게시 View가 현재 draft interaction token과 일치하지 않습니다."),
})
end
return success({
applied = false,
stale = true,
interactionAction = interactionAction,
interactionToken = interacted.interactionToken,
draft = interacted.draft,
view = published.view,
})
end
if interacted.applied then
local writeErrors = writeStored(KEYS.draft, interacted.draft)
if writeErrors then
return failure(writeErrors)
end
local submissionWriteErrors = writeStored(
KEYS.submission,
interacted.interactionToken
)
if submissionWriteErrors then return failure(submissionWriteErrors) end
end
local published, publishErrors = publishCurrentViewInternal(staticData, true, false, true)
if publishErrors then
return failure(publishErrors)
end
if published.view.interactionToken ~= interacted.interactionToken then
return failure({
makeError("view_interaction_token_mismatch", "$.view.interactionToken", "카드 전이 View가 계산된 다음 interaction token과 일치하지 않습니다."),
})
end
return success({
applied = interacted.applied,
stale = false,
interactionAction = interactionAction,
interactionToken = interacted.interactionToken,
draft = interacted.draft,
view = published.view,
})
end
local function clickCard(instanceId, expectedInteractionToken)
return interactCard("click", instanceId, expectedInteractionToken)
end
local function registerCard(instanceId, expectedInteractionToken)
return interactCard("register", instanceId, expectedInteractionToken)
end
local function cancelCard(instanceId, expectedInteractionToken)
return interactCard("cancel", instanceId, expectedInteractionToken)
end
local function selectCardEffect(instanceId, choiceId, expectedInteractionToken)
return interactCard("choose", instanceId, expectedInteractionToken, choiceId)
end
local function armSubmission(expectedInteractionToken)
if type(expectedInteractionToken) ~= "string" or expectedInteractionToken == "" then
return failure({
makeError("invalid_interaction_token", "$.expectedInteractionToken", "비어 있지 않은 draft interaction token이 필요합니다."),
})
end
local staticData, staticErrors = loadStaticData()
if staticErrors then return failure(staticErrors) end
local authority, authorityErrors = readStored(KEYS.authority, true)
if authorityErrors then return failure(authorityErrors) end
local pending, pendingErrors = readStored(KEYS.pending, false)
if pendingErrors then return failure(pendingErrors) end
if pending ~= nil then
return failure({
makeError("battle_view_locked", "$.pendingTurn", "출력 대기 중에는 턴 제출을 다시 준비할 수 없습니다."),
})
end
local activeRequest, requestErrors = readStored(KEYS.activeRequest, false)
if requestErrors then return failure(requestErrors) end
if type(activeRequest) == "table" then
local activeErrors = validateBinding(activeRequest, nil)
if #activeErrors > 0 then return failure(activeErrors) end
if activeRequest.phase ~= "committed" then
return failure({
makeError("battle_view_locked", "$.activeRequest.phase", "생성 요청 처리 중에는 턴 제출을 다시 준비할 수 없습니다."),
})
end
end
local draft, draftErrors = readStored(KEYS.draft, true)
if draftErrors then return failure(draftErrors) end
local interactionToken, tokenErrors = inspectDraftInteractionToken(
authority,
staticData,
draft
)
if tokenErrors then return failure(tokenErrors) end
if interactionToken ~= expectedInteractionToken then
local published, publishErrors = publishCurrentViewInternal(
staticData,
true,
false,
true
)
if publishErrors then return failure(publishErrors) end
return success({
applied = false,
stale = true,
interactionToken = interactionToken,
view = published.view,
})
end
local writeErrors = writeStored(KEYS.submission, interactionToken)
if writeErrors then return failure(writeErrors) end
local published, publishErrors = publishCurrentViewInternal(
staticData,
true,
false,
true
)
if publishErrors then return failure(publishErrors) end
return success({
applied = true,
stale = false,
interactionToken = interactionToken,
view = published.view,
})
end
local loadBoundPending
local commitOutput
local function loadAndValidateBoundRequest(binding, staticData)
local pending, pendingErrors = loadBoundPending(binding)
if pendingErrors then return nil, nil, pendingErrors end
local formatted, formatErrors = formatPending(pending, staticData)
if formatErrors then return nil, nil, formatErrors end
if binding.publicMarker ~= formatted.publicMarker
or not deepEqual(binding.message, formatted.message) then
return nil, nil, {
makeError("active_request_prompt_mismatch", "$.activeRequest", "복구할 요청 binding이 pendingTurn에서 다시 만든 프롬프트와 다릅니다."),
}
end
return pending, formatted, nil
end
local function validateNoPendingCommitTrace(binding, authority)
if binding.source ~= "pending" then return nil end
local lastCommitted, lastErrors = readStored(KEYS.lastCommittedPending, false)
if lastErrors then return lastErrors end
if authority.lastCommittedTurnId == binding.turnId
or (type(lastCommitted) == "table" and lastCommitted.turnId == binding.turnId) then
return {
makeError("commit_trace_without_output_receipt", "$.activeRequest", "현재 턴의 commit 흔적이 있어 출력 관측 영수증 없이 요청을 재시도할 수 없습니다."),
}
end
return nil
end
local function beginRecoveringCleanup(binding, chat, staticData, authority)
local pending, _, boundErrors = loadAndValidateBoundRequest(binding, staticData)
if boundErrors then return nil, nil, nil, boundErrors end
local topology, topologyErrors = inspectAnchoredTopology(binding, chat, true)
if topologyErrors then return nil, nil, nil, topologyErrors end
local mode = binding.outputObserved ~= nil and "resumeCommit" or "retry"
if mode == "resumeCommit" then
if not topology.responsePresent
or topology.responseIndex ~= binding.outputObserved.responseIndex
or not fingerprintsEqual(topology.responseFingerprint, binding.outputObserved.responseFingerprint) then
return nil, nil, nil, {
makeError("observed_output_mismatch", "$.chat", "완성 출력 복구 대상이 출력 관측 영수증과 다릅니다."),
}
end
else
local traceErrors = validateNoPendingCommitTrace(binding, authority)
if traceErrors then return nil, nil, nil, traceErrors end
end
local nextBinding, cloneError = cloneJson(binding, "$.activeRequest")
if cloneError then return nil, nil, nil, { cloneError } end
nextBinding.recoveringCleanup = {
schemaVersion = 1,
kind = "battleRecoveringCleanup",
mode = mode,
originalPhase = binding.phase,
attemptNumber = binding.attemptNumber,
responseIndex = topology.responseIndex,
responsePresent = topology.responsePresent,
responseFingerprint = topology.responseFingerprint,
initialFillerCount = topology.fillerCount,
}
local validationErrors = validateBinding(nextBinding, pending)
if #validationErrors > 0 then return nil, nil, nil, validationErrors end
local writeErrors = writeStored(KEYS.activeRequest, nextBinding, true)
if writeErrors then return nil, nil, nil, writeErrors end
return nextBinding, pending, topology, nil
end
local function resumeRecoveringCleanup(binding, chat)
local receipt = binding.recoveringCleanup
local topology, topologyErrors = inspectAnchoredTopology(binding, chat, false)
if topologyErrors then return nil, nil, nil, topologyErrors end
if receipt.responsePresent then
if topology.responsePresent
and (topology.responseIndex ~= receipt.responseIndex
or not fingerprintsEqual(topology.responseFingerprint, receipt.responseFingerprint)) then
return nil, nil, nil, {
makeError("cleanup_response_mismatch", "$.chat", "현재 미확정 응답이 복구 정리 영수증과 다릅니다."),
}
end
elseif topology.responsePresent then
return nil, nil, nil, {
makeError("unexpected_cleanup_response", "$.chat", "응답이 없었던 복구 정리에 새 캐릭터 메시지가 나타났습니다."),
}
end
local nextChat, removedFillers, fillerErrors = removeTrailingSayNothing(chat)
if fillerErrors then return nil, nil, nil, fillerErrors end
local removedResponse = false
if receipt.mode == "retry" and receipt.responsePresent then
local afterFillerTopology, afterFillerErrors = inspectAnchoredTopology(binding, nextChat, false)
if afterFillerErrors then return nil, nil, nil, afterFillerErrors end
if afterFillerTopology.responsePresent then
local afterRemoval, removeErrors = removeRecoveryResponse(
nextChat,
afterFillerTopology.responseLuaIndex,
receipt.responseFingerprint
)
if removeErrors then return nil, nil, nil, removeErrors end
nextChat = afterRemoval
removedResponse = true
end
end
local finalTopology, finalErrors = inspectAnchoredTopology(binding, nextChat, false)
if finalErrors then return nil, nil, nil, finalErrors end
if receipt.mode == "retry" and finalTopology.responsePresent then
return nil, nil, nil, {
makeError("cleanup_response_remains", "$.chat", "삭제 재시도 정리 뒤 미확정 응답이 남아 있습니다."),
}
end
if receipt.mode == "resumeCommit" then
if not finalTopology.responsePresent
or finalTopology.responseIndex ~= binding.outputObserved.responseIndex
or not fingerprintsEqual(finalTopology.responseFingerprint, binding.outputObserved.responseFingerprint) then
return nil, nil, nil, {
makeError("observed_output_mismatch", "$.chat", "commit 재개 전에 완성 응답이 출력 관측 영수증과 달라졌습니다."),
}
end
end
return nextChat, removedFillers, removedResponse, nil
end
local function retryWithoutOutput(binding, chat, staticData, authority)
if binding.outputObserved ~= nil or binding.recoveringCleanup ~= nil then
return failure({
makeError("zero_output_retry_has_receipt", "$.activeRequest", "출력 또는 정리 영수증이 있는 요청을 무출력 재시도로 되돌릴 수 없습니다."),
})
end
local pending, _, boundErrors = loadAndValidateBoundRequest(binding, staticData)
if boundErrors then return failure(boundErrors) end
local topology, topologyErrors = inspectAnchoredTopology(binding, chat, false)
if topologyErrors then return failure(topologyErrors) end
if topology.responsePresent or topology.fillerCount ~= 0 then
return failure({
makeError("zero_output_retry_topology_mismatch", "$.chat", "무출력 재시도에는 요청 응답 위치가 완전히 비어 있어야 합니다."),
})
end
local traceErrors = validateNoPendingCommitTrace(binding, authority)
if traceErrors then return failure(traceErrors) end
local nextBinding, cloneError = cloneJson(binding, "$.activeRequest")
if cloneError then return failure({ cloneError }) end
nextBinding.phase = "inFlight"
nextBinding.attemptNumber = nextBinding.attemptNumber + 1
local validationErrors = validateBinding(nextBinding, pending)
if #validationErrors > 0 then return failure(validationErrors) end
-- The visible View is already locked and does not expose attemptNumber.
-- Publish it before the sole state mutation so a publication failure
-- cannot consume a retry attempt.
local published, publishErrors = publishCurrentViewInternal(staticData)
if publishErrors then return failure(publishErrors) end
local writeErrors = writeStored(KEYS.activeRequest, nextBinding, true)
if writeErrors then return failure(writeErrors) end
return success({
generationReady = true,
recoveredAbandonedRequest = true,
zeroOutputRetry = true,
commitRecovered = false,
turnId = nextBinding.turnId,
turnNumber = nextBinding.turnNumber,
source = nextBinding.source,
publicMarker = nextBinding.publicMarker,
attemptNumber = nextBinding.attemptNumber,
reused = true,
removedSayNothing = false,
removedSayNothingCount = 0,
removedUncommittedOutput = false,
markerAdded = false,
view = published.view,
})
end
local function recoverLockedRequest(binding, chat, staticData, authority)
local workingBinding = binding
local pending
if workingBinding.recoveringCleanup == nil then
local topology
local beginErrors
workingBinding, pending, topology, beginErrors = beginRecoveringCleanup(
workingBinding,
chat,
staticData,
authority
)
if beginErrors then return failure(beginErrors) end
else
local boundErrors
pending, _, boundErrors = loadAndValidateBoundRequest(workingBinding, staticData)
if boundErrors then return failure(boundErrors) end
end
local cleanedChat, removedFillers, removedResponse, cleanupErrors = resumeRecoveringCleanup(
workingBinding,
chat
)
if cleanupErrors then return failure(cleanupErrors) end
if workingBinding.recoveringCleanup.mode == "resumeCommit" then
local committed = commitOutput()
if type(committed) ~= "table" or committed.ok ~= true then return committed end
committed.generationReady = false
committed.commitRecovered = true
committed.zeroOutputRetry = false
committed.removedSayNothing = removedFillers > 0
committed.removedSayNothingCount = removedFillers
committed.removedUncommittedOutput = false
return committed
end
workingBinding.phase = "inFlight"
workingBinding.attemptNumber = workingBinding.attemptNumber + 1
workingBinding.outputObserved = nil
workingBinding.recoveringCleanup = nil
local validationErrors = validateBinding(workingBinding, pending)
if #validationErrors > 0 then return failure(validationErrors) end
local writeErrors = writeStored(KEYS.activeRequest, workingBinding, true)
if writeErrors then return failure(writeErrors) end
local published, publishErrors = publishCurrentViewInternal(staticData)
if publishErrors then return failure(publishErrors) end
return success({
generationReady = true,
recoveredAbandonedRequest = true,
zeroOutputRetry = false,
commitRecovered = false,
turnId = workingBinding.turnId,
turnNumber = workingBinding.turnNumber,
source = workingBinding.source,
publicMarker = workingBinding.publicMarker,
attemptNumber = workingBinding.attemptNumber,
reused = true,
removedSayNothing = removedFillers > 0,
removedSayNothingCount = removedFillers,
removedUncommittedOutput = removedResponse,
markerAdded = false,
view = published.view,
})
end
local function retryCommittedOutput(binding, chat, staticData)
local repairedChat, repairedTail, repairErrors = restoreRepairableAnchorTail(
binding,
chat
)
if repairErrors then return failure(repairErrors) end
local topology, topologyErrors = inspectAnchoredTopology(
binding,
repairedChat,
false
)
if topologyErrors then return failure(topologyErrors) end
if topology.responsePresent or topology.fillerCount ~= 0 then
return failure({
makeError("committed_reroll_topology_mismatch", "$.chat", "확정 응답 리롤 뒤 응답 위치가 비어 있지 않습니다."),
})
end
local pending, _, boundErrors = loadAndValidateBoundRequest(binding, staticData)
if boundErrors then return failure(boundErrors) end
local retryChat, markerAdded, boundaryErrors = ensureTurnBoundary(repairedChat)
if boundaryErrors then return failure(boundaryErrors) end
local nextBinding, cloneError = cloneJson(binding, "$.activeRequest")
if cloneError then return failure({ cloneError }) end
if markerAdded then
local anchor, anchorErrors = createChatAnchor(retryChat, #retryChat)
if anchorErrors then return failure(anchorErrors) end
nextBinding.chatAnchor = anchor
end
nextBinding.phase = "inFlight"
nextBinding.attemptNumber = nextBinding.attemptNumber + 1
nextBinding.outputObserved = nil
nextBinding.recoveringCleanup = nil
local validationErrors = validateBinding(nextBinding, pending)
if #validationErrors > 0 then return failure(validationErrors) end
local submissionClearErrors = clearSubmission()
if submissionClearErrors then return failure(submissionClearErrors) end
local writeErrors = writeStored(KEYS.activeRequest, nextBinding, true)
if writeErrors then return failure(writeErrors) end
local published, publishErrors = publishCurrentViewInternal(staticData)
if publishErrors then return failure(publishErrors) end
return success({
generationReady = true,
rerolledCommittedOutput = true,
repairedStoryTail = repairedTail,
recoveredAbandonedRequest = false,
zeroOutputRetry = false,
commitRecovered = false,
turnId = nextBinding.turnId,
turnNumber = nextBinding.turnNumber,
source = nextBinding.source,
publicMarker = nextBinding.publicMarker,
attemptNumber = nextBinding.attemptNumber,
reused = true,
removedSayNothing = false,
removedSayNothingCount = 0,
removedUncommittedOutput = false,
markerAdded = markerAdded,
view = published.view,
})
end
local function prepareGeneration()
local staticData, staticErrors = loadStaticData()
if staticErrors then return failure(staticErrors) end
local authority, authorityErrors = readStored(KEYS.authority, true)
if authorityErrors then return failure(authorityErrors) end
local aftermath, aftermathReadErrors = readStored(KEYS.aftermath, false)
if aftermathReadErrors then return failure(aftermathReadErrors) end
if aftermath ~= nil then
local aftermathErrors = validateAftermath(aftermath, authority)
if #aftermathErrors > 0 then return failure(aftermathErrors) end
local migrated, migrationErrors = migrateLegacyAftermath(authority, aftermath)
if migrationErrors then return failure(migrationErrors) end
aftermath = migrated
return prepareAftermathGeneration(authority, aftermath, staticData)
end
local chat, chatErrors = readChat()
if chatErrors then return failure(chatErrors) end
local removedCount = 0
local markerAdded = false
local storedBinding, storedBindingErrors = readStored(KEYS.activeRequest, false)
if storedBindingErrors then return failure(storedBindingErrors) end
if storedBinding ~= nil then
local storedBindingValidationErrors = validateBinding(storedBinding, nil)
if #storedBindingValidationErrors > 0 then return failure(storedBindingValidationErrors) end
if storedBinding.phase == "committed" then
local stalePending, stalePendingErrors = readStored(KEYS.pending, false)
if stalePendingErrors then return failure(stalePendingErrors) end
if type(stalePending) == "table" and stalePending.turnId == storedBinding.turnId then
local cleanupCommit = commitOutput()
if type(cleanupCommit) ~= "table" or cleanupCommit.ok ~= true then return cleanupCommit end
authority, authorityErrors = readStored(KEYS.authority, true)
if authorityErrors then return failure(authorityErrors) end
chat, chatErrors = readChat()
if chatErrors then return failure(chatErrors) end
chat, removedCount, chatErrors = removeTrailingSayNothing(chat)
if chatErrors then return failure(chatErrors) end
storedBinding, storedBindingErrors = readStored(KEYS.activeRequest, true)
if storedBindingErrors then return failure(storedBindingErrors) end
end
end
if storedBinding.phase == "committed" then
local committedChat, repairedTail, repairErrors = restoreRepairableAnchorTail(
storedBinding,
chat
)
if repairErrors then return failure(repairErrors) end
chat = committedChat
local committedTopology, committedTopologyErrors = inspectAnchoredTopology(
storedBinding,
chat,
false
)
if committedTopologyErrors then return failure(committedTopologyErrors) end
if not committedTopology.responsePresent then
if committedTopology.fillerCount > 0 then
chat, removedCount, chatErrors = removeTrailingSayNothing(chat)
if chatErrors then return failure(chatErrors) end
end
local retried = retryCommittedOutput(
storedBinding,
chat,
staticData
)
if type(retried) == "table" and retried.ok == true and repairedTail then
retried.repairedStoryTail = true
end
return retried
end
end
if storedBinding.phase == "inFlight" or storedBinding.phase == "requestInjected" then
if storedBinding.recoveringCleanup == nil then
local topology, topologyErrors = inspectAnchoredTopology(storedBinding, chat, false)
if topologyErrors then return failure(topologyErrors) end
if storedBinding.outputObserved == nil
and not topology.responsePresent
and topology.fillerCount == 0 then
return retryWithoutOutput(storedBinding, chat, staticData, authority)
end
if storedBinding.outputObserved ~= nil
and topology.responsePresent
and topology.fillerCount == 0 then
local committed = commitOutput()
if type(committed) ~= "table" or committed.ok ~= true then return committed end
committed.generationReady = false
committed.commitRecovered = true
committed.zeroOutputRetry = false
committed.removedSayNothing = false
committed.removedSayNothingCount = 0
committed.removedUncommittedOutput = false
return committed
end
if topology.fillerCount == 0 then
return failure({
makeError(
"request_already_in_flight",
"$.activeRequest.phase",
"이미 생성 중이거나 프롬프트 주입을 마친 요청이 있습니다. 응답을 리롤한 뒤 다시 시도하세요."
),
})
end
end
return recoverLockedRequest(storedBinding, chat, staticData, authority)
end
end
chat, removedCount, chatErrors = removeTrailingSayNothing(chat)
if chatErrors then return failure(chatErrors) end
local selectedPending
local sourceName
local reused = false
local recoveryBinding
local pending, pendingErrors = readStored(KEYS.pending, false)
if pendingErrors then return failure(pendingErrors) end
if type(storedBinding) == "table" and storedBinding.phase == "preparing" then
local boundPending, boundPendingErrors = loadBoundPending(storedBinding)
if boundPendingErrors then
return failure(boundPendingErrors)
end
selectedPending = boundPending
sourceName = storedBinding.source
recoveryBinding = storedBinding
reused = true
elseif pending ~= nil then
local reuse, reuseErrors = callModule(
"battleRuntime",
"reusePending",
authority,
staticData,
pending
)
if reuseErrors then return failure(reuseErrors) end
selectedPending = reuse.pendingTurn
sourceName = "pending"
reused = true
else
local draft, draftErrors = readStored(KEYS.draft, true)
if draftErrors then return failure(draftErrors) end
local submission, submissionErrors = readArmedSubmission(
authority,
staticData,
draft
)
if submissionErrors then return failure(submissionErrors) end
if submission ~= nil then
local projected, projectErrors = callModule(
"turnDraft",
"project",
authority,
staticData,
draft
)
if projectErrors then
return failure(projectErrors)
end
if type(projected.projection) ~= "table" then
return failure({
makeError("missing_turn_projection", "$.runtime.turnDraft.projection", "전송할 turnDraft projection이 없습니다."),
})
end
local prepared, prepareErrors = callModule(
"battleRuntime",
"preparePending",
authority,
staticData,
projected.projection
)
if prepareErrors then
return failure(prepareErrors)
end
selectedPending = prepared.pendingTurn
sourceName = "pending"
end
end
if selectedPending == nil then
if type(storedBinding) == "table" and storedBinding.phase == "committed" then
local published, publishErrors = publishCurrentViewInternal(staticData, true)
if publishErrors then return failure(publishErrors) end
return success({
generationReady = false,
idle = true,
uiTargetRequired = true,
uiTargetIndex = storedBinding.chatAnchor.responseIndex,
outputCommitted = false,
commitRecovered = false,
turnId = storedBinding.turnId,
turnNumber = storedBinding.turnNumber,
source = storedBinding.source,
publicMarker = storedBinding.publicMarker,
attemptNumber = storedBinding.attemptNumber,
reused = true,
removedSayNothing = removedCount > 0,
removedSayNothingCount = removedCount,
removedUncommittedOutput = false,
markerAdded = false,
view = published.view,
})
end
return success({
generationReady = false,
idle = true,
removedSayNothing = removedCount > 0,
removedSayNothingCount = removedCount,
})
end
if type(selectedPending) ~= "table" then
return failure({
makeError("missing_selected_pending", "$.pendingTurn", "생성 요청에 연결할 pendingTurn이 없습니다."),
})
end
local formatted, formatErrors = formatPending(selectedPending, staticData)
if formatErrors then
return failure(formatErrors)
end
local binding
if recoveryBinding ~= nil then
if recoveryBinding.publicMarker ~= formatted.publicMarker
or not deepEqual(recoveryBinding.message, formatted.message) then
return failure({
makeError("active_request_prompt_mismatch", "$.activeRequest", "준비 중인 요청 binding이 pendingTurn에서 다시 만든 프롬프트와 다릅니다."),
})
end
binding = recoveryBinding
local topology, topologyErrors = inspectPreparingTopology(binding, chat)
if topologyErrors then return failure(topologyErrors) end
else
local boundaryErrors
chat, markerAdded, boundaryErrors = ensureTurnBoundary(chat)
if boundaryErrors then return failure(boundaryErrors) end
local chatAnchor, anchorErrors = createPlannedChatAnchor(chat)
if anchorErrors then return failure(anchorErrors) end
local bindingErrors
binding, bindingErrors = buildBinding(
selectedPending,
formatted,
sourceName,
"preparing",
chatAnchor,
1
)
if bindingErrors then
return failure(bindingErrors)
end
end
if sourceName == "pending" then
local pendingWriteErrors = writeStored(KEYS.pending, selectedPending)
if pendingWriteErrors then
return failure(pendingWriteErrors)
end
local draftClearErrors = writeStored(KEYS.draft, nil)
if draftClearErrors then
return failure(draftClearErrors)
end
end
local bindingWriteErrors = writeStored(KEYS.activeRequest, binding)
if bindingWriteErrors then
return failure(bindingWriteErrors)
end
local submissionClearErrors = clearSubmission()
if submissionClearErrors then return failure(submissionClearErrors) end
-- 생성 중에는 잠긴 canonical View만 영속한다. 전체 HTML/CBS 렌더는
-- 응답 commit 뒤 게시하므로 HTTP 요청 전 중복 평가하지 않는다.
local published, publishErrors = publishCurrentViewInternal(staticData, true, true)
if publishErrors then
return failure(publishErrors)
end
local finalTopology, finalTopologyErrors = inspectAnchoredTopology(binding, chat, false)
if finalTopologyErrors then return failure(finalTopologyErrors) end
if finalTopology.responsePresent or finalTopology.fillerCount ~= 0 then
return failure({
makeError("prepared_chat_topology_mismatch", "$.chat", "요청 준비 뒤 응답 위치에 예상하지 않은 메시지가 남아 있습니다."),
})
end
binding.phase = "inFlight"
binding.outputObserved = nil
binding.recoveringCleanup = nil
local finalBindingErrors = validateBinding(binding, selectedPending)
if #finalBindingErrors > 0 then return failure(finalBindingErrors) end
local readyWriteErrors = writeStored(KEYS.activeRequest, binding, true)
if readyWriteErrors then
return failure(readyWriteErrors)
end
return success({
generationReady = true,
turnId = binding.turnId,
turnNumber = binding.turnNumber,
source = binding.source,
publicMarker = binding.publicMarker,
reused = reused,
removedSayNothing = removedCount > 0,
removedSayNothingCount = removedCount,
markerAdded = markerAdded,
attemptNumber = binding.attemptNumber,
recoveredAbandonedRequest = false,
zeroOutputRetry = false,
commitRecovered = false,
removedUncommittedOutput = false,
view = published.view,
})
end
loadBoundPending = function(binding)
local bindingErrors = validateBinding(binding, nil)
if #bindingErrors > 0 then
return nil, bindingErrors
end
local key = binding.source == "pending" and KEYS.pending or KEYS.lastCommittedPending
local pending, pendingErrors = readStored(key, true)
if pendingErrors then
return nil, pendingErrors
end
local linkedErrors = validateBinding(binding, pending)
if #linkedErrors > 0 then
return nil, linkedErrors
end
return pending, nil
end
local function injectRequest(promptArray)
local authority, authorityErrors = readStored(KEYS.authority, true)
if authorityErrors then return failure(authorityErrors) end
local aftermath, aftermathReadErrors = readStored(KEYS.aftermath, false)
if aftermathReadErrors then return failure(aftermathReadErrors) end
if aftermath ~= nil then
local aftermathErrors = validateAftermath(aftermath, authority)
if #aftermathErrors > 0 then return failure(aftermathErrors) end
local migrated, migrationErrors = migrateLegacyAftermath(authority, aftermath)
if migrationErrors then return failure(migrationErrors) end
aftermath = migrated
return injectAftermathRequest(promptArray, authority, aftermath)
end
local promptCopy, cloneError = cloneJson(promptArray, "$.promptArray")
if cloneError then
return failure({ cloneError })
end
if type(promptCopy) ~= "table" then
return failure({
makeError("invalid_prompt_array", "$.promptArray", "editRequest 값은 메시지 배열이어야 합니다."),
})
end
local count = 0
for key, message in pairs(promptCopy) do
if type(key) ~= "number" or not isInteger(key, 1) or type(message) ~= "table" then
return failure({
makeError("invalid_prompt_array", "$.promptArray", "editRequest 값은 1부터 이어지는 메시지 객체 배열이어야 합니다."),
})
end
count = count + 1
end
if count ~= #promptCopy then
return failure({
makeError("invalid_prompt_array", "$.promptArray", "editRequest 메시지 배열에 빈 인덱스가 있습니다."),
})
end
local binding, bindingReadErrors = readStored(KEYS.activeRequest, true)
if bindingReadErrors then
return failure(bindingReadErrors)
end
local pending, pendingErrors = loadBoundPending(binding)
if pendingErrors then
return failure(pendingErrors)
end
if binding.phase ~= "inFlight" and binding.phase ~= "requestInjected" then
return failure({
makeError("request_not_in_flight", "$.activeRequest.phase", "editRequest에는 inFlight 또는 같은 requestInjected 요청 binding이 필요합니다."),
})
end
if binding.recoveringCleanup ~= nil then
return failure({
makeError("request_cleanup_in_progress", "$.activeRequest.recoveringCleanup", "복구 정리 중에는 프롬프트를 주입할 수 없습니다."),
})
end
if binding.outputObserved ~= nil then
return failure({
makeError("request_output_already_observed", "$.activeRequest.outputObserved", "이미 완성 출력이 관측된 요청에는 프롬프트를 다시 주입할 수 없습니다."),
})
end
local staticData, staticErrors = loadStaticData()
if staticErrors then
return failure(staticErrors)
end
local formatted, formatErrors = formatPending(pending, staticData)
if formatErrors then
return failure(formatErrors)
end
if binding.publicMarker ~= formatted.publicMarker or not deepEqual(binding.message, formatted.message) then
return failure({
makeError("active_request_prompt_mismatch", "$.activeRequest", "저장된 요청 binding이 pendingTurn에서 다시 만든 프롬프트와 다릅니다."),
})
end
local requestCue = {
role = "user",
content = formatted.publicMarker,
}
local systemCount = 0
local cueCount = 0
local normalizedPrompt = {}
for _, message in ipairs(promptCopy) do
if deepEqual(message, formatted.message) then
systemCount = systemCount + 1
elseif deepEqual(message, requestCue) then
cueCount = cueCount + 1
elseif message.role == "user" and message.content == TURN_SUBMIT_MARKER then
-- 이전 배포가 남긴 표시 전용 marker는 모델 요청에 포함하지 않는다.
else
normalizedPrompt[#normalizedPrompt + 1] = message
end
end
local alreadyInjected = systemCount >= 1 and cueCount >= 1
local deduplicated = systemCount > 1 or cueCount > 1
promptCopy = normalizedPrompt
local messageCopy, messageCloneError = cloneJson(formatted.message, "$.activeRequest.message")
if messageCloneError then
return failure({ messageCloneError })
end
local cueCopy, cueCloneError = cloneJson(requestCue, "$.activeRequest.publicMarker")
if cueCloneError then
return failure({ cueCloneError })
end
-- 확정 사건을 먼저 제공하고 장면 묘사 지시를 마지막 user 메시지로 둔다.
-- 두 메시지는 editRequest 결과에만 존재하며 실제 채팅에는 저장하지 않는다.
promptCopy[#promptCopy + 1] = messageCopy
promptCopy[#promptCopy + 1] = cueCopy
-- This durable phase is the authority boundary between editRequest and
-- onOutput. Without it, a host-side editRequest failure could still run
-- onOutput and commit a turn whose event/cue never reached the model.
if binding.phase == "inFlight" then
binding.phase = "requestInjected"
local receiptWriteErrors = writeStored(KEYS.activeRequest, binding, true)
if receiptWriteErrors then
return failure(receiptWriteErrors)
end
end
return success({
promptArray = promptCopy,
injected = not alreadyInjected,
deduplicated = deduplicated,
turnId = binding.turnId,
requestPhase = binding.phase,
})
end
local function prepareCurrentActiveTurn(state, staticData, allowExistingDraft)
if state.status ~= "active" then
return state, nil, false, nil
end
if allowExistingDraft then
local currentDraft, draftReadErrors = readStored(KEYS.draft, false)
if draftReadErrors then
return nil, nil, false, draftReadErrors
end
if currentDraft ~= nil then
local validated, validateErrors = callModule(
"turnDraft",
"validate",
state,
staticData,
currentDraft
)
if validateErrors then
return nil, nil, false, validateErrors
end
return state, validated.draft, false, nil
end
end
local turnId, turnIdErrors = makeTurnId(state)
if turnIdErrors then
return nil, nil, false, turnIdErrors
end
local initialized, initializeErrors = callModule(
"turnInitializer",
"prepareTurn",
state,
staticData,
{ turnId = turnId }
)
if initializeErrors then
return nil, nil, false, initializeErrors
end
if type(initialized.state) ~= "table" or type(initialized.draft) ~= "table" then
return nil, nil, false, {
makeError("invalid_next_turn", "$.runtime.turnInitializer", "다음 활성 턴 상태와 draft가 없습니다."),
}
end
return initialized.state, initialized.draft, initialized.reused ~= true, nil
end
commitOutput = function()
local staticData, staticErrors = loadStaticData()
if staticErrors then
return failure(staticErrors)
end
local authority, authorityErrors = readStored(KEYS.authority, true)
if authorityErrors then
return failure(authorityErrors)
end
local aftermath, aftermathReadErrors = readStored(KEYS.aftermath, false)
if aftermathReadErrors then return failure(aftermathReadErrors) end
if aftermath ~= nil then
local aftermathErrors = validateAftermath(aftermath, authority)
if #aftermathErrors > 0 then return failure(aftermathErrors) end
local migrated, migrationErrors = migrateLegacyAftermath(authority, aftermath)
if migrationErrors then return failure(migrationErrors) end
aftermath = migrated
if aftermath.phase == "ready" then
local published, publishErrors = publishCurrentViewInternal(staticData, true)
if publishErrors then return failure(publishErrors) end
return success({
generationReady = false,
outputCommitted = true,
uiTargetIndex = aftermath.lastCommitted.responseLuaIndex - 1,
aftermathComplete = false,
status = authority.status,
view = published.view,
})
elseif aftermath.phase == "complete" then
return success({
generationReady = false,
outputCommitted = true,
uiTargetIndex = aftermath.lastCommitted.responseLuaIndex - 1,
aftermathComplete = true,
status = authority.status,
})
end
return commitAftermathOutput(authority, aftermath, staticData)
end
local binding, bindingErrors = readStored(KEYS.activeRequest, true)
if bindingErrors then
return failure(bindingErrors)
end
local bindingValidationErrors = validateBinding(binding, nil)
if #bindingValidationErrors > 0 then
return failure(bindingValidationErrors)
end
if binding.phase ~= "requestInjected" and binding.phase ~= "committed" then
return failure({
makeError("request_not_committable", "$.activeRequest.phase", "출력 확정에는 requestInjected 또는 이미 committed인 요청 binding이 필요합니다."),
})
end
if binding.recoveringCleanup ~= nil
and binding.recoveringCleanup.mode ~= "resumeCommit" then
return failure({
makeError("request_cleanup_in_progress", "$.activeRequest.recoveringCleanup", "미확정 출력 삭제 복구 중에는 출력을 확정할 수 없습니다."),
})
end
local selectedPending, pendingErrors = loadBoundPending(binding)
if pendingErrors then
return failure(pendingErrors)
end
local formatted, formatErrors = formatPending(selectedPending, staticData)
if formatErrors then
return failure(formatErrors)
end
if binding.publicMarker ~= formatted.publicMarker or not deepEqual(binding.message, formatted.message) then
return failure({
makeError("active_request_prompt_mismatch", "$.activeRequest", "출력과 연결된 prompt binding이 pendingTurn과 다릅니다."),
})
end
if binding.phase == "requestInjected" then
local chat, chatErrors = readChat()
if chatErrors then return failure(chatErrors) end
local topology, topologyErrors = inspectObservedOutput(binding, chat, false)
if topologyErrors then return failure(topologyErrors) end
if isRisuErrorResponse(chat[topology.responseLuaIndex]) then
return failure({
makeError("battle_output_error", "$.chat", "LLM 오류 응답은 전투 턴으로 확정할 수 없습니다."),
})
end
if binding.outputObserved == nil then
local observedBinding, cloneError = cloneJson(binding, "$.activeRequest")
if cloneError then return failure({ cloneError }) end
observedBinding.outputObserved = {
schemaVersion = 1,
kind = "battleOutputObserved",
attemptNumber = binding.attemptNumber,
responseIndex = topology.responseIndex,
responseFingerprint = topology.responseFingerprint,
}
local observedErrors = validateBinding(observedBinding, selectedPending)
if #observedErrors > 0 then return failure(observedErrors) end
local observedWriteErrors = writeStored(KEYS.activeRequest, observedBinding, true)
if observedWriteErrors then return failure(observedWriteErrors) end
binding = observedBinding
end
end
local committed, commitErrors = callModule(
"battleRuntime",
"commitPending",
authority,
staticData,
selectedPending
)
if commitErrors then
return failure(commitErrors)
end
if type(committed.state) ~= "table" or committed.turnId ~= binding.turnId then
return failure({
makeError("invalid_commit_result", "$.runtime.battleRuntime", "commit 결과가 활성 요청과 연결되지 않습니다."),
})
end
if binding.phase == "committed" and committed.applied == true then
return failure({
makeError("committed_request_reapplied", "$.activeRequest", "committed 요청이 새 상태 변경으로 다시 적용되었습니다."),
})
end
local nextState, nextDraft, initialized, nextErrors = prepareCurrentActiveTurn(
committed.state,
staticData,
committed.applied ~= true
)
if nextErrors then
return failure(nextErrors)
end
local committedBinding, committedBindingErrors = buildBinding(
selectedPending,
formatted,
"lastCommittedPending",
"committed",
binding.chatAnchor,
binding.attemptNumber
)
if committedBindingErrors then
return failure(committedBindingErrors)
end
local committedBindingValidationErrors = validateBinding(committedBinding, selectedPending)
if #committedBindingValidationErrors > 0 then
return failure(committedBindingValidationErrors)
end
local lastWriteErrors = writeStored(KEYS.lastCommittedPending, selectedPending)
if lastWriteErrors then
return failure(lastWriteErrors)
end
local authorityWriteErrors = writeStored(KEYS.authority, nextState, true)
if authorityWriteErrors then
return failure(authorityWriteErrors)
end
local draftWriteErrors = writeStored(KEYS.draft, nextDraft)
if draftWriteErrors then
return failure(draftWriteErrors)
end
local bindingWriteErrors = writeStored(KEYS.activeRequest, committedBinding, true)
if bindingWriteErrors then
return failure(bindingWriteErrors)
end
-- Move the binding first. If either write is silently dropped, a retry can
-- still locate the same turn through pending or lastCommittedPending.
local storedPending, storedPendingErrors = readStored(KEYS.pending, false)
if storedPendingErrors then
return failure(storedPendingErrors)
end
if type(storedPending) == "table" and storedPending.turnId == selectedPending.turnId then
local pendingClearErrors = writeStored(KEYS.pending, nil)
if pendingClearErrors then
return failure(pendingClearErrors)
end
end
-- 활성 전투는 다음 턴 View를, 종료 전투는 멱등 정산 뒤 결과/보상 View를
-- 게시한다. onOutput 훅이 완성 응답을 새 UI target으로 지정한다.
local published
local progressionState
if nextState.status == "active" then
local publishErrors
published, publishErrors = publishCurrentViewInternal(staticData, true)
if publishErrors then return failure(publishErrors) end
elseif nextState.status == "victory" and nextState.turnNumber < nextState.turnLimit then
local currentAftermath, aftermathErrors = readStored(KEYS.aftermath, false)
if aftermathErrors then return failure(aftermathErrors) end
if currentAftermath == nil then
local aftermathChat, chatErrors = readChat()
if chatErrors then return failure(chatErrors) end
local committedReceipt, receiptErrors = buildAftermathCommitted(
aftermathChat,
nextState.turnNumber,
#aftermathChat
)
if receiptErrors then return failure(receiptErrors) end
currentAftermath = {
schemaVersion = AFTERMATH_SCHEMA_VERSION,
kind = "battleAftermath",
battleId = nextState.battleId,
victoryTurnNumber = nextState.turnNumber,
completedTurnNumber = nextState.turnNumber,
phase = "ready",
lastCommitted = committedReceipt,
}
local validationErrors = validateAftermath(currentAftermath, nextState)
if #validationErrors > 0 then return failure(validationErrors) end
local writeErrors = writeStored(KEYS.aftermath, currentAftermath)
if writeErrors then return failure(writeErrors) end
else
local validationErrors = validateAftermath(currentAftermath, nextState)
if #validationErrors > 0 then return failure(validationErrors) end
end
local publishErrors
published, publishErrors = publishCurrentViewInternal(staticData, true)
if publishErrors then return failure(publishErrors) end
else
local summary, summaryErrors = buildTerminalSummary(
nextState,
selectedPending,
staticData
)
if summaryErrors then return failure(summaryErrors) end
local _, battleLogErrors = publishTerminalBattleLog(nextState, staticData)
if battleLogErrors then return failure(battleLogErrors) end
local settled, settlementErrors = callModule(
"gameSetupController",
"completeBattle",
summary
)
if settlementErrors then return failure(settlementErrors) end
if type(settled.view) ~= "table" or type(settled.state) ~= "table" then
return failure({
makeError(
"invalid_settlement_result",
"$.runtime.gameSetupController.completeBattle",
"전투 정산이 진행 상태와 결과 View를 반환하지 않았습니다."
),
})
end
published = { view = settled.view }
progressionState = settled.state
end
return success({
generationReady = false,
outputCommitted = true,
uiTargetIndex = committedBinding.chatAnchor.responseIndex,
turnId = committed.turnId,
applied = committed.applied == true,
initializedNextTurn = initialized,
status = nextState.status,
turnNumber = nextState.turnNumber,
publicResult = selectedPending.turnResult.publicResult,
view = published.view,
progressionState = progressionState,
})
end
local function getSnapshot()
local snapshot = {
keys = {
authority = KEYS.authority,
draft = KEYS.draft,
pending = KEYS.pending,
lastCommittedPending = KEYS.lastCommittedPending,
activeRequest = KEYS.activeRequest,
submission = KEYS.submission,
aftermath = KEYS.aftermath,
},
}
local authority, authorityErrors = readStored(KEYS.authority, false)
if authorityErrors then return failure(authorityErrors) end
local draft, draftErrors = readStored(KEYS.draft, false)
if draftErrors then return failure(draftErrors) end
local pending, pendingErrors = readStored(KEYS.pending, false)
if pendingErrors then return failure(pendingErrors) end
local lastCommitted, lastErrors = readStored(KEYS.lastCommittedPending, false)
if lastErrors then return failure(lastErrors) end
local activeRequest, requestErrors = readStored(KEYS.activeRequest, false)
if requestErrors then return failure(requestErrors) end
local submission, submissionErrors = readStored(KEYS.submission, false)
if submissionErrors then return failure(submissionErrors) end
local aftermath, aftermathErrors = readStored(KEYS.aftermath, false)
if aftermathErrors then return failure(aftermathErrors) end
snapshot.hasBattle = authority ~= nil
snapshot.authorityState = authority
snapshot.draft = draft
snapshot.pendingTurn = pending
snapshot.lastCommittedPending = lastCommitted
snapshot.activeRequest = activeRequest
snapshot.submission = submission
snapshot.aftermath = aftermath
if type(lastCommitted) == "table" and type(lastCommitted.turnResult) == "table" then
snapshot.lastPublicResult = lastCommitted.turnResult.publicResult
end
return success({ snapshot = snapshot })
end
local function publishCurrentView()
local staticData, staticErrors = loadStaticData()
if staticErrors then
return failure(staticErrors)
end
local published, publishErrors = publishCurrentViewInternal(staticData)
if publishErrors then
return failure(publishErrors)
end
return success(published)
end
local arguments = { ... }
if action == "startVerticalSlice" then
return startVerticalSlice(arguments[1], arguments[2])
elseif action == "_startFromCanonicalSetup" then
if not allowsCanonicalHandoff(arguments[2], "battleControllerCanonicalSetupV1") then
return failure({
makeError("internal_action_denied", "$.action", "내부 canonical setup 인계 작업에 접근할 수 없습니다."),
})
end
return startFromSetup(arguments[1], true)
elseif action == "_startFromCanonicalRun" then
if not allowsCanonicalHandoff(arguments[3], "battleControllerCanonicalRunV1") then
return failure({
makeError("internal_action_denied", "$.action", "내부 canonical run 인계 작업에 접근할 수 없습니다."),
})
end
return startFromRun(arguments[1], arguments[2], true)
elseif action == "startFromSetup" then
return startFromSetup(arguments[1])
elseif action == "startFromRun" then
return startFromRun(arguments[1], arguments[2])
elseif action == "clickCard" then
return clickCard(arguments[1], arguments[2])
elseif action == "registerCard" then
return registerCard(arguments[1], arguments[2])
elseif action == "cancelCard" then
return cancelCard(arguments[1], arguments[2])
elseif action == "selectCardEffect" then
return selectCardEffect(arguments[1], arguments[2], arguments[3])
elseif action == "armSubmission" then
return armSubmission(arguments[1])
elseif action == "prepareGeneration" then
return prepareGeneration()
elseif action == "injectRequest" then
return injectRequest(arguments[1])
elseif action == "commitOutput" then
return commitOutput()
elseif action == "skipAftermath" then
return skipAftermath(arguments[1], arguments[2])
elseif action == "publishCurrentView" then
return publishCurrentView()
elseif action == "getSnapshot" then
return getSnapshot()
elseif action == "getTerminalSummary" then
return getTerminalSummary()
end
return failure({
makeError("unknown_action", "$.action", "지원하지 않는 battleController 작업입니다: " .. tostring(action)),
})
end)
#30battleHistory.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local HISTORY_KIND = "battleHistory"
local CONTEXT_KIND = "battleHistoryContext"
local MAX_TURNS = 12
local VALID_SIDE = {
player = true,
character = true,
}
local VALID_STATUS = {
active = true,
victory = true,
defeat = true,
}
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
errors = errors,
}
end
local function success(fields)
local report = {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
}
for key, value in pairs(fields or {}) do
report[key] = value
end
return report
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and (minimum == nil or value >= minimum)
end
local function isAsciiId(value)
return type(value) == "string"
and string.match(value, "^[a-z][a-z0-9_]*$") ~= nil
end
local function isRuntimeId(value)
return type(value) == "string"
and string.match(value, "^[A-Za-z0-9][A-Za-z0-9_-]*$") ~= nil
end
local function isDenseArray(value)
if type(value) ~= "table" or getmetatable(value) ~= nil then
return false
end
local count = 0
local maximum = 0
for key in pairs(value) do
if not isInteger(key, 1) then
return false
end
count = count + 1
if key > maximum then maximum = key end
end
return count == maximum
end
local function cloneData(value, path, active)
local valueType = type(value)
if valueType == "nil" or valueType == "string" or valueType == "boolean" then
return value, nil
end
if valueType == "number" then
if not isFinite(value) then
return nil, makeError("non_finite_number", path, "전투 이력에는 유한한 숫자만 저장할 수 있습니다.")
end
return value, nil
end
if valueType ~= "table" or getmetatable(value) ~= nil then
return nil, makeError("unsupported_type", path, "전투 이력에는 JSON-safe 일반 테이블만 저장할 수 있습니다.")
end
active = active or {}
if active[value] then
return nil, makeError("circular_reference", path, "전투 이력에는 순환 참조를 저장할 수 없습니다.")
end
active[value] = true
local copy = {}
for key, item in pairs(value) do
local keyType = type(key)
if keyType ~= "string" and not isInteger(key, 1) then
active[value] = nil
return nil, makeError("invalid_key", path, "전투 이력 키는 문자열 또는 양의 배열 인덱스여야 합니다.")
end
local childPath = keyType == "number"
and (path .. "[" .. key .. "]")
or (path .. "." .. tostring(key))
local itemCopy, itemError = cloneData(item, childPath, active)
if itemError then
active[value] = nil
return nil, itemError
end
copy[key] = itemCopy
end
active[value] = nil
return copy, nil
end
local function dataEqual(left, right, seen)
if type(left) ~= type(right) then return false end
if type(left) ~= "table" then return left == right end
if getmetatable(left) ~= nil or getmetatable(right) ~= nil then return false end
seen = seen or {}
if seen[left] ~= nil then return seen[left] == right end
seen[left] = right
for key, value in pairs(left) do
if not dataEqual(value, right[key], seen) then return false end
end
for key in pairs(right) do
if left[key] == nil then return false end
end
return true
end
local function normalizeStaticData(staticData)
if type(staticData) == "table" and type(staticData.data) == "table" then
return staticData.data
end
return staticData
end
local function emptyHistory()
return {
schemaVersion = SCHEMA_VERSION,
kind = HISTORY_KIND,
turns = {},
}
end
local function addError(errors, code, path, message)
errors[#errors + 1] = makeError(code, path, message)
end
local function validateSnapshot(value, path, errors, staticData, allowStatus)
if type(value) ~= "table" or getmetatable(value) ~= nil then
addError(errors, "invalid_history_snapshot", path, "턴 상태 스냅샷이 일반 객체가 아닙니다.")
return
end
if not isFinite(value.stealth) then
addError(errors, "invalid_history_stealth", path .. ".stealth", "기록된 은폐가 유한한 숫자가 아닙니다.")
end
if not isFinite(value.resistance) then
addError(errors, "invalid_history_resistance", path .. ".resistance", "기록된 저항이 유한한 숫자가 아닙니다.")
end
if not isAsciiId(value.mood) then
addError(errors, "invalid_history_mood", path .. ".mood", "기록된 무드 ID가 올바르지 않습니다.")
elseif type(staticData) == "table"
and type(staticData.registry) == "table"
and type(staticData.registry.moods) == "table"
and staticData.registry.moods[value.mood] == nil then
addError(errors, "unknown_history_mood", path .. ".mood", "기록된 무드를 정적 레지스트리에서 찾을 수 없습니다.")
end
if allowStatus then
if not VALID_STATUS[value.status] then
addError(errors, "invalid_history_status", path .. ".status", "기록된 전투 상태가 올바르지 않습니다.")
end
elseif value.status ~= nil then
addError(errors, "unexpected_history_status", path .. ".status", "턴 시작 스냅샷에는 전투 상태를 저장하지 않습니다.")
end
end
local function validateCardRecord(record, side, path, errors, staticData)
if type(record) ~= "table" or getmetatable(record) ~= nil then
addError(errors, "invalid_history_card", path, "카드 이력 항목이 일반 객체가 아닙니다.")
return
end
if not isAsciiId(record.cardId) then
addError(errors, "invalid_history_card_id", path .. ".cardId", "카드 이력 ID가 올바르지 않습니다.")
end
if not isRuntimeId(record.instanceId) then
addError(errors, "invalid_history_instance_id", path .. ".instanceId", "카드 이력 인스턴스 ID가 올바르지 않습니다.")
end
if not isDenseArray(record.roles) or #record.roles < 1 or #record.roles > 2 or (side == "character" and #record.roles ~= 1) then
addError(errors, "invalid_history_roles", path .. ".roles", "카드 이력 역할 태그 목록이 올바르지 않습니다.")
else
local seenRoles = {}
for roleIndex, role in ipairs(record.roles) do
local definition = type(staticData) == "table"
and type(staticData.registry) == "table"
and type(staticData.registry.roles) == "table"
and staticData.registry.roles[role]
or nil
if not isAsciiId(role) or seenRoles[role] or (definition ~= nil and definition.owner ~= side) then
addError(errors, "invalid_history_role", path .. ".roles[" .. roleIndex .. "]", "카드 이력 역할 태그가 올바르지 않습니다.")
end
seenRoles[role] = true
end
end
for _, field in ipairs({ "selected", "declared", "resolved" }) do
if type(record[field]) ~= "boolean" then
addError(errors, "invalid_history_card_flag", path .. "." .. field, "카드 이력 상태 표시는 불리언이어야 합니다.")
end
end
if record.declarationSequence ~= nil and not isInteger(record.declarationSequence, 1) then
addError(errors, "invalid_history_sequence", path .. ".declarationSequence", "선언 사건 순번이 올바르지 않습니다.")
end
if record.resolutionSequence ~= nil and not isInteger(record.resolutionSequence, 1) then
addError(errors, "invalid_history_sequence", path .. ".resolutionSequence", "해결 사건 순번이 올바르지 않습니다.")
end
if record.resolved == true and record.resolutionSequence == nil then
addError(errors, "missing_history_resolution", path .. ".resolutionSequence", "해결된 카드에는 해결 사건 순번이 필요합니다.")
end
if record.finalStealthCost ~= nil and not isFinite(record.finalStealthCost) then
addError(errors, "invalid_history_cost", path .. ".finalStealthCost", "최종 은폐 비용이 유한한 숫자가 아닙니다.")
end
if record.finalResistanceDamage ~= nil and not isFinite(record.finalResistanceDamage) then
addError(errors, "invalid_history_damage", path .. ".finalResistanceDamage", "최종 저항 피해가 유한한 숫자가 아닙니다.")
end
local card = type(staticData) == "table"
and type(staticData.cards) == "table"
and staticData.cards[record.cardId]
or nil
if type(staticData) == "table" and type(staticData.cards) == "table" then
if type(card) ~= "table" then
addError(errors, "unknown_history_card", path .. ".cardId", "카드 이력의 정적 카드 정의를 찾을 수 없습니다.")
elseif card.owner ~= side then
addError(errors, "history_card_owner_mismatch", path .. ".cardId", "카드 이력 진영과 정적 카드 소유자가 다릅니다.")
elseif not dataEqual(card.roles, record.roles) then
addError(errors, "history_card_role_mismatch", path .. ".roles", "카드 이력 역할 태그와 정적 카드 정의가 다릅니다.")
end
end
end
local function validateRoleCounts(value, path, errors, staticData)
if type(value) ~= "table" or getmetatable(value) ~= nil then
addError(errors, "invalid_history_role_counts", path, "역할 태그 집계가 일반 객체가 아닙니다.")
return
end
for roleId, count in pairs(value) do
if not isAsciiId(roleId) then
addError(errors, "invalid_history_role", path, "집계 역할 태그 ID가 올바르지 않습니다.")
elseif not isInteger(count, 0) then
addError(errors, "invalid_history_role_count", path .. "." .. tostring(roleId), "역할 태그 횟수는 0 이상의 정수여야 합니다.")
elseif type(staticData) == "table"
and type(staticData.registry) == "table"
and type(staticData.registry.roles) == "table"
and staticData.registry.roles[roleId] == nil then
addError(errors, "unknown_history_role", path .. "." .. roleId, "집계 역할 태그를 정적 레지스트리에서 찾을 수 없습니다.")
end
end
end
local function validatePublicResult(value, path, errors)
if value == nil then return end
if type(value) ~= "table" or getmetatable(value) ~= nil then
addError(errors, "invalid_history_public_result", path, "공개 전투 로그 묶음이 일반 객체가 아닙니다.")
return
end
if value.schemaVersion ~= SCHEMA_VERSION or not isDenseArray(value.events) then
addError(errors, "invalid_history_public_result", path, "공개 전투 로그 사건 묶음이 올바르지 않습니다.")
end
end
local function countCards(cards, field)
local counts = {}
for _, card in ipairs(cards or {}) do
if card[field] == true then
for _, role in ipairs(card.roles or {}) do
counts[role] = (counts[role] or 0) + 1
end
end
end
return counts
end
local function validateTurnEntry(entry, index, errors, staticData)
local path = "$.turns[" .. index .. "]"
if type(entry) ~= "table" or getmetatable(entry) ~= nil then
addError(errors, "invalid_history_turn", path, "턴 이력 항목이 일반 객체가 아닙니다.")
return
end
if entry.turnNumber ~= index then
addError(errors, "history_turn_sequence_mismatch", path .. ".turnNumber", "턴 이력 번호가 배열 순서와 다릅니다.")
end
if not isRuntimeId(entry.turnId) then
addError(errors, "invalid_history_turn_id", path .. ".turnId", "턴 이력 ID가 올바르지 않습니다.")
end
validateSnapshot(entry.start, path .. ".start", errors, staticData, false)
validateSnapshot(entry.finish, path .. ".finish", errors, staticData, true)
local startMood = type(entry.start) == "table" and entry.start.mood or nil
local finishMood = type(entry.finish) == "table" and entry.finish.mood or nil
if type(entry.mood) ~= "table" or getmetatable(entry.mood) ~= nil then
addError(errors, "invalid_history_mood_result", path .. ".mood", "무드 판정 이력이 일반 객체가 아닙니다.")
else
if entry.mood.before ~= startMood or entry.mood.after ~= finishMood then
addError(errors, "history_mood_snapshot_mismatch", path .. ".mood", "무드 판정과 턴 시작·종료 스냅샷이 다릅니다.")
end
if type(entry.mood.applied) ~= "boolean" then
addError(errors, "invalid_history_mood_applied", path .. ".mood.applied", "무드 변경 여부는 불리언이어야 합니다.")
end
if not isInteger(entry.mood.forcedCount, 0) then
addError(errors, "invalid_history_forced_mood_count", path .. ".mood.forcedCount", "강제 무드 요청 수가 올바르지 않습니다.")
end
if type(entry.mood.forceCancelled) ~= "boolean" then
addError(errors, "invalid_history_force_cancelled", path .. ".mood.forceCancelled", "강제 무드 상쇄 여부는 불리언이어야 합니다.")
end
if not isAsciiId(entry.mood.resolution) then
addError(errors, "invalid_history_mood_resolution", path .. ".mood.resolution", "무드 판정 종류가 올바르지 않습니다.")
end
end
if type(entry.cards) ~= "table" then
addError(errors, "invalid_history_cards", path .. ".cards", "턴 카드 이력이 객체가 아닙니다.")
else
for _, side in ipairs({ "player", "character" }) do
local cards = entry.cards[side]
if not isDenseArray(cards) then
addError(errors, "invalid_history_cards", path .. ".cards." .. side, "진영별 카드 이력이 연속 배열이 아닙니다.")
else
local seen = {}
for cardIndex, card in ipairs(cards) do
validateCardRecord(card, side, path .. ".cards." .. side .. "[" .. cardIndex .. "]", errors, staticData)
if type(card) == "table" and isRuntimeId(card.instanceId) then
if seen[card.instanceId] then
addError(errors, "duplicate_history_card", path .. ".cards." .. side .. "[" .. cardIndex .. "].instanceId", "같은 카드 인스턴스가 한 턴 이력에 중복되었습니다.")
end
seen[card.instanceId] = true
end
end
end
end
end
if type(entry.roleCounts) ~= "table" then
addError(errors, "invalid_history_role_counts", path .. ".roleCounts", "턴 역할 태그 집계가 객체가 아닙니다.")
else
for _, side in ipairs({ "player", "character" }) do
local sideCounts = entry.roleCounts[side]
if type(sideCounts) ~= "table" then
addError(errors, "invalid_history_role_counts", path .. ".roleCounts." .. side, "진영별 역할 태그 집계가 객체가 아닙니다.")
else
validateRoleCounts(sideCounts.declared, path .. ".roleCounts." .. side .. ".declared", errors, staticData)
validateRoleCounts(sideCounts.resolved, path .. ".roleCounts." .. side .. ".resolved", errors, staticData)
if type(entry.cards) == "table" and isDenseArray(entry.cards[side]) then
if not dataEqual(sideCounts.declared, countCards(entry.cards[side], "declared")) then
addError(errors, "history_declared_count_mismatch", path .. ".roleCounts." .. side .. ".declared", "선언 역할 집계가 카드 이력과 다릅니다.")
end
if not dataEqual(sideCounts.resolved, countCards(entry.cards[side], "resolved")) then
addError(errors, "history_resolved_count_mismatch", path .. ".roleCounts." .. side .. ".resolved", "해결 역할 집계가 카드 이력과 다릅니다.")
end
end
end
end
end
validatePublicResult(entry.publicResult, path .. ".publicResult", errors)
end
local function validateHistory(history, state, staticInput)
local errors = {}
local staticData = normalizeStaticData(staticInput)
if type(history) ~= "table" or getmetatable(history) ~= nil then
return failure({ makeError("invalid_battle_history", "$", "전투 이력이 일반 객체가 아닙니다.") })
end
if history.schemaVersion ~= SCHEMA_VERSION or history.kind ~= HISTORY_KIND then
addError(errors, "invalid_battle_history_schema", "$", "지원하지 않는 전투 이력 형식입니다.")
end
if not isDenseArray(history.turns) then
addError(errors, "invalid_battle_history_turns", "$.turns", "전투 턴 이력이 연속 배열이 아닙니다.")
elseif #history.turns > MAX_TURNS then
addError(errors, "battle_history_too_long", "$.turns", "전투 이력은 최대 12턴까지만 저장할 수 있습니다.")
else
local seenTurnIds = {}
for index, entry in ipairs(history.turns) do
validateTurnEntry(entry, index, errors, staticData)
if type(entry) == "table" and isRuntimeId(entry.turnId) then
if seenTurnIds[entry.turnId] then
addError(errors, "duplicate_history_turn_id", "$.turns[" .. index .. "].turnId", "턴 이력 ID가 중복되었습니다.")
end
seenTurnIds[entry.turnId] = true
end
if index > 1 then
local previous = history.turns[index - 1]
if type(previous) == "table"
and type(previous.finish) == "table"
and type(entry) == "table"
and type(entry.start) == "table" then
if previous.finish.stealth ~= entry.start.stealth
or previous.finish.resistance ~= entry.start.resistance
or previous.finish.mood ~= entry.start.mood then
addError(errors, "battle_history_discontinuity", "$.turns[" .. index .. "].start", "이전 턴 종료 상태와 다음 턴 시작 상태가 이어지지 않습니다.")
end
end
end
end
end
if type(state) == "table" and isDenseArray(history.turns) then
local count = #history.turns
local expectedCount = nil
if state.status == "active" and isInteger(state.turnNumber, 1) then
expectedCount = state.turnNumber - 1
elseif (state.status == "victory" or state.status == "defeat") and isInteger(state.turnNumber, 1) then
expectedCount = state.turnNumber
end
if expectedCount ~= nil and count ~= expectedCount then
addError(errors, "battle_history_turn_count_mismatch", "$.turns", "전투 상태와 완료된 턴 이력 수가 다릅니다.")
end
local last = history.turns[count]
if count == 0 then
if state.lastCommittedTurnId ~= nil then
addError(errors, "battle_history_missing_last_turn", "$.turns", "마지막 확정 턴 ID가 있지만 전투 이력이 비어 있습니다.")
end
elseif type(last) == "table" and state.lastCommittedTurnId ~= last.turnId then
addError(errors, "battle_history_last_turn_mismatch", "$.turns[" .. count .. "].turnId", "전투 이력의 마지막 턴과 권위 상태의 마지막 확정 턴이 다릅니다.")
end
if type(last) == "table" and type(last.finish) == "table" then
local currentStealth = type(state.player) == "table" and state.player.stealth or nil
local currentResistance = type(state.character) == "table" and state.character.resistance or nil
local currentMood = type(state.character) == "table" and state.character.mood or nil
local receipt = state.status == "active" and state.turnStartReceipt or nil
local baseline = type(receipt) == "table" and receipt.baseline or nil
if isInteger(state.turnNumber, 1)
and count == state.turnNumber - 1
and state.lastCommittedTurnId == last.turnId
and type(receipt) == "table"
and receipt.turnNumber == state.turnNumber
and type(baseline) == "table"
and isFinite(baseline.stealth)
and isFinite(baseline.resistance)
and type(baseline.mood) == "string" then
currentStealth = baseline.stealth
currentResistance = baseline.resistance
currentMood = baseline.mood
end
if type(state.player) == "table" and currentStealth ~= last.finish.stealth then
addError(errors, "battle_history_stealth_mismatch", "$.turns[" .. count .. "].finish.stealth", "마지막 이력의 은폐가 현재 상태와 다릅니다.")
end
if type(state.character) == "table"
and (currentResistance ~= last.finish.resistance
or currentMood ~= last.finish.mood) then
addError(errors, "battle_history_character_mismatch", "$.turns[" .. count .. "].finish", "마지막 이력의 저항 또는 무드가 현재 상태와 다릅니다.")
end
if state.status ~= last.finish.status then
addError(errors, "battle_history_status_mismatch", "$.turns[" .. count .. "].finish.status", "마지막 이력의 전투 상태가 현재 상태와 다릅니다.")
end
end
end
if #errors > 0 then return failure(errors) end
local copy, copyError = cloneData(history, "$", {})
if copyError then return failure({ copyError }) end
return success({ history = copy })
end
local function findInstance(state, instanceId)
for _, instance in ipairs(type(state) == "table" and type(state.cardInstances) == "table" and state.cardInstances or {}) do
if type(instance) == "table" and instance.instanceId == instanceId then
return instance
end
end
return nil
end
local function selectedSet(selected)
local set = {}
for _, instanceId in ipairs(type(selected) == "table" and selected or {}) do
set[instanceId] = true
end
return set
end
local function buildCardRecords(beforeState, events, selectedCards, staticData)
local records = { player = {}, character = {} }
local byInstance = { player = {}, character = {} }
local selected = {
player = selectedSet(type(selectedCards) == "table" and selectedCards.player or nil),
character = selectedSet(type(selectedCards) == "table" and selectedCards.character or nil),
}
local function ensureRecord(side, cardId, instanceId)
if not VALID_SIDE[side] or not isAsciiId(cardId) or not isRuntimeId(instanceId) then
return nil
end
local existing = byInstance[side][instanceId]
if existing then return existing end
local card = type(staticData) == "table"
and type(staticData.cards) == "table"
and staticData.cards[cardId]
or nil
if type(card) ~= "table" or card.owner ~= side or not isDenseArray(card.roles) then
return nil
end
local roles = {}
for index, role in ipairs(card.roles) do roles[index] = role end
local record = {
cardId = cardId,
instanceId = instanceId,
roles = roles,
selected = selected[side][instanceId] == true,
declared = false,
resolved = false,
}
records[side][#records[side] + 1] = record
byInstance[side][instanceId] = record
return record
end
for _, event in ipairs(type(events) == "table" and events or {}) do
local source = type(event) == "table" and event.source or nil
local side = type(event) == "table" and (event.side or (type(source) == "table" and source.side)) or nil
if type(source) == "table" and source.kind == "card" and VALID_SIDE[side] then
local record = ensureRecord(side, source.id, source.instanceId)
if record and event.type == "card_declared" then
record.declared = true
record.declarationSequence = event.sequence
local payload = type(event.payload) == "table" and event.payload or nil
if payload and isFinite(payload.finalStealthCost) then
record.finalStealthCost = payload.finalStealthCost
end
elseif record and event.type == "card_resolved" then
record.resolved = true
record.resolutionSequence = event.sequence
local payload = type(event.payload) == "table" and event.payload or nil
if payload and isFinite(payload.finalResistanceDamage) then
record.finalResistanceDamage = payload.finalResistanceDamage
end
end
end
end
for _, side in ipairs({ "player", "character" }) do
local selectedIds = type(selectedCards) == "table" and selectedCards[side] or nil
for _, instanceId in ipairs(type(selectedIds) == "table" and selectedIds or {}) do
if byInstance[side][instanceId] == nil then
local instance = findInstance(beforeState, instanceId)
if type(instance) == "table" then
ensureRecord(side, instance.cardId, instance.instanceId)
end
end
end
table.sort(records[side], function(left, right)
local leftSequence = left.declarationSequence or (100000 + (left.resolutionSequence or 0))
local rightSequence = right.declarationSequence or (100000 + (right.resolutionSequence or 0))
if leftSequence ~= rightSequence then return leftSequence < rightSequence end
return left.instanceId < right.instanceId
end)
end
return records
end
local function appendResolvedTurn(beforeState, afterState, spec, staticInput)
local staticData = normalizeStaticData(staticInput)
if type(beforeState) ~= "table" or type(afterState) ~= "table" or type(spec) ~= "table" then
return failure({ makeError("invalid_append_input", "$", "턴 이력 추가 입력이 올바르지 않습니다.") })
end
local beforeValidation = validateHistory(beforeState.history, beforeState, staticData)
if not beforeValidation.ok then return beforeValidation end
if not dataEqual(beforeState.history, afterState.history) then
return failure({ makeError("history_changed_before_append", "$.afterState.history", "턴 해결 전에 전투 이력이 임의로 변경되었습니다.") })
end
if not isInteger(spec.turnNumber, 1)
or spec.turnNumber ~= #beforeState.history.turns + 1
or not isRuntimeId(spec.turnId)
or not isDenseArray(spec.events) then
return failure({ makeError("invalid_turn_history_spec", "$.spec", "턴 이력 추가 사양이 현재 전투 순서와 맞지 않습니다.") })
end
local stateCopy, stateError = cloneData(afterState, "$.afterState", {})
if stateError then return failure({ stateError }) end
local historyCopy, historyError = cloneData(beforeState.history, "$.history", {})
if historyError then return failure({ historyError }) end
local cards = buildCardRecords(beforeState, spec.events, spec.selectedCards, staticData)
local startCopy, startError = cloneData(spec.start, "$.spec.start", {})
if startError then return failure({ startError }) end
local finishCopy, finishError = cloneData(spec.finish, "$.spec.finish", {})
if finishError then return failure({ finishError }) end
local moodCopy, moodError = cloneData(spec.mood, "$.spec.mood", {})
if moodError then return failure({ moodError }) end
local entry = {
turnNumber = spec.turnNumber,
turnId = spec.turnId,
start = startCopy,
finish = finishCopy,
mood = moodCopy,
cards = cards,
roleCounts = {
player = {
declared = countCards(cards.player, "declared"),
resolved = countCards(cards.player, "resolved"),
},
character = {
declared = countCards(cards.character, "declared"),
resolved = countCards(cards.character, "resolved"),
},
},
}
historyCopy.turns[#historyCopy.turns + 1] = entry
stateCopy.history = historyCopy
local validation = validateHistory(historyCopy, stateCopy, staticData)
if not validation.ok then return validation end
local entryCopy, entryError = cloneData(entry, "$.entry", {})
if entryError then return failure({ entryError }) end
return success({ state = stateCopy, history = validation.history, entry = entryCopy })
end
local function attachPublicResult(state, turnId, publicResult, staticInput)
local staticData = normalizeStaticData(staticInput)
if type(state) ~= "table" or not isRuntimeId(turnId) then
return failure({ makeError("invalid_public_log_input", "$", "공개 턴 로그 연결 입력이 올바르지 않습니다.") })
end
local validation = validateHistory(state.history, state, staticData)
if not validation.ok then return validation end
local eventErrors = {}
validatePublicResult(publicResult, "$.publicResult", eventErrors)
if #eventErrors > 0 then return failure(eventErrors) end
local stateCopy, stateError = cloneData(state, "$.state", {})
if stateError then return failure({ stateError }) end
local turns = stateCopy.history.turns
local entry = turns[#turns]
if type(entry) ~= "table" or entry.turnId ~= turnId then
return failure({ makeError("public_log_turn_mismatch", "$.state.history.turns", "공개 로그를 연결할 마지막 턴을 찾을 수 없습니다.") })
end
if entry.publicResult ~= nil and not dataEqual(entry.publicResult, publicResult) then
return failure({ makeError("public_log_conflict", "$.state.history.turns", "같은 턴에 서로 다른 공개 로그를 연결할 수 없습니다.") })
end
local publicCopy, publicError = cloneData(publicResult, "$.publicResult", {})
if publicError then return failure({ publicError }) end
entry.publicResult = publicCopy
local outputValidation = validateHistory(stateCopy.history, stateCopy, staticData)
if not outputValidation.ok then return outputValidation end
return success({ state = stateCopy, history = outputValidation.history })
end
local function copyCounts(source)
local copy = {}
for key, value in pairs(type(source) == "table" and source or {}) do
copy[key] = value
end
return copy
end
local function addCounts(target, source)
for key, value in pairs(type(source) == "table" and source or {}) do
target[key] = (target[key] or 0) + value
end
end
local function cardContextRecord(record)
local roles = {}
for index, role in ipairs(record.roles) do roles[index] = role end
return {
cardId = record.cardId,
instanceId = record.instanceId,
roles = roles,
selected = record.selected,
declared = record.declared,
resolved = record.resolved,
}
end
local function turnContext(entry)
local value = {
turnNumber = entry.turnNumber,
turnId = entry.turnId,
startMood = entry.start.mood,
endMood = entry.finish.mood,
status = entry.finish.status,
start = {
stealth = entry.start.stealth,
resistance = entry.start.resistance,
mood = entry.start.mood,
},
finish = {
stealth = entry.finish.stealth,
resistance = entry.finish.resistance,
mood = entry.finish.mood,
status = entry.finish.status,
},
mood = {
before = entry.mood.before,
after = entry.mood.after,
applied = entry.mood.applied,
forcedCount = entry.mood.forcedCount,
forceCancelled = entry.mood.forceCancelled,
resolution = entry.mood.resolution,
tokensBefore = copyCounts(entry.mood.tokensBefore),
tokensAfter = copyCounts(entry.mood.tokensAfter),
},
playerCards = {},
characterCards = {},
playerRoles = {},
characterRoles = {},
}
if entry.mood.targetMood ~= nil then value.mood.targetMood = entry.mood.targetMood end
if type(entry.mood.tiedMoods) == "table" then
value.mood.tiedMoods = {}
for index, moodId in ipairs(entry.mood.tiedMoods) do
value.mood.tiedMoods[index] = moodId
end
end
for _, side in ipairs({ "player", "character" }) do
local outputCards = side == "player" and value.playerCards or value.characterCards
local outputRoles = side == "player" and value.playerRoles or value.characterRoles
for _, record in ipairs(entry.cards[side]) do
outputCards[#outputCards + 1] = cardContextRecord(record)
if record.resolved then
for _, role in ipairs(record.roles) do outputRoles[#outputRoles + 1] = role end
end
end
end
return value
end
local function buildContext(history)
local validation = validateHistory(history, nil, nil)
if not validation.ok then return validation end
local turns = validation.history.turns
local context = {
schemaVersion = SCHEMA_VERSION,
kind = CONTEXT_KIND,
completedTurns = #turns,
turns = {},
windows = {},
player = {
declaredRoleCounts = {},
resolvedRoleCounts = {},
},
character = {
declaredRoleCounts = {},
resolvedRoleCounts = {},
},
}
for index, entry in ipairs(turns) do
context.turns[index] = turnContext(entry)
for _, side in ipairs({ "player", "character" }) do
addCounts(context[side].declaredRoleCounts, entry.roleCounts[side].declared)
addCounts(context[side].resolvedRoleCounts, entry.roleCounts[side].resolved)
for _, card in ipairs(entry.cards[side]) do
if card.declared then context[side].lastDeclaredRoles = cardContextRecord(card).roles end
if card.resolved then context[side].lastResolvedRoles = cardContextRecord(card).roles end
end
end
end
if #turns > 0 then
context.previousTurn = turnContext(turns[#turns])
end
for window = 1, MAX_TURNS do
local availableTurns = math.min(window, #turns)
local windowValue = {
requestedTurns = window,
availableTurns = availableTurns,
player = { declaredRoleCounts = {}, resolvedRoleCounts = {} },
character = { declaredRoleCounts = {}, resolvedRoleCounts = {} },
}
local first = math.max(1, #turns - window + 1)
for index = first, #turns do
local entry = turns[index]
for _, side in ipairs({ "player", "character" }) do
addCounts(windowValue[side].declaredRoleCounts, entry.roleCounts[side].declared)
addCounts(windowValue[side].resolvedRoleCounts, entry.roleCounts[side].resolved)
end
end
context.windows[window] = windowValue
end
return success({ context = context })
end
local function validateContext(value)
if type(value) ~= "table" or getmetatable(value) ~= nil then
return failure({ makeError("invalid_history_context", "$", "전투 이력 컨텍스트가 일반 객체가 아닙니다.") })
end
local errors = {}
if value.schemaVersion ~= SCHEMA_VERSION or value.kind ~= CONTEXT_KIND then
addError(errors, "invalid_history_context_schema", "$", "지원하지 않는 전투 이력 컨텍스트입니다.")
end
if not isInteger(value.completedTurns, 0) then
addError(errors, "invalid_history_context_turns", "$.completedTurns", "완료 턴 수가 올바르지 않습니다.")
end
if not isDenseArray(value.turns) or not isDenseArray(value.windows) then
addError(errors, "invalid_history_context_arrays", "$", "컨텍스트 턴 또는 기간 집계가 연속 배열이 아닙니다.")
elseif #value.turns ~= value.completedTurns or #value.windows ~= MAX_TURNS then
addError(errors, "history_context_count_mismatch", "$", "컨텍스트 완료 턴 수 또는 기간 창 개수가 다릅니다.")
else
for window = 1, MAX_TURNS do
local current = value.windows[window]
if type(current) ~= "table"
or current.requestedTurns ~= window
or current.availableTurns ~= math.min(window, value.completedTurns) then
addError(errors, "invalid_history_window", "$.windows[" .. window .. "]", "기간 창의 요청·가용 턴 수가 올바르지 않습니다.")
else
for _, side in ipairs({ "player", "character" }) do
local sideValue = current[side]
if type(sideValue) ~= "table"
or type(sideValue.declaredRoleCounts) ~= "table"
or type(sideValue.resolvedRoleCounts) ~= "table" then
addError(errors, "invalid_history_window_side", "$.windows[" .. window .. "]." .. side, "기간 창의 진영별 태그 집계가 올바르지 않습니다.")
end
end
end
end
end
for _, side in ipairs({ "player", "character" }) do
if type(value[side]) ~= "table"
or type(value[side].declaredRoleCounts) ~= "table"
or type(value[side].resolvedRoleCounts) ~= "table" then
addError(errors, "invalid_history_context_side", "$." .. side, "진영별 이력 집계가 올바르지 않습니다.")
end
end
if #errors > 0 then return failure(errors) end
return success({ valid = true })
end
local function numberText(value)
if type(value) ~= "number" then return tostring(value) end
if value % 1 == 0 then return string.format("%.0f", value) end
return tostring(value)
end
local function moodLabel(staticData, moodId)
local mood = type(staticData) == "table"
and type(staticData.registry) == "table"
and type(staticData.registry.moods) == "table"
and staticData.registry.moods[moodId]
or nil
return type(mood) == "table" and mood.label or tostring(moodId)
end
local function cardName(staticData, cardId)
local card = type(staticData) == "table"
and type(staticData.cards) == "table"
and staticData.cards[cardId]
or nil
return type(card) == "table" and card.name or tostring(cardId)
end
local function roleLabel(staticData, roleId)
local role = type(staticData) == "table"
and type(staticData.registry) == "table"
and type(staticData.registry.roles) == "table"
and staticData.registry.roles[roleId]
or nil
return type(role) == "table" and role.label or tostring(roleId)
end
local function roleLabels(staticData, roles)
local labels = {}
for index, roleId in ipairs(roles or {}) do labels[index] = roleLabel(staticData, roleId) end
return table.concat(labels, "·")
end
local EFFECT_TEXT = {
pay_stealth_cost = "은폐 비용",
lose_stealth = "은폐 감소",
recover_stealth = "은폐 회복",
damage_resistance = "저항 피해",
recover_resistance = "저항 회복",
}
local function validatePublicView(view)
local errors = {}
if type(view) ~= "table" or getmetatable(view) ~= nil then
return failure({ makeError("invalid_battle_log_view", "$", "상세 전투 로그 View가 일반 객체가 아닙니다.") })
end
local allowedViewKeys = {
available = true,
turnCount = true,
entries = true,
}
for key in pairs(view) do
if type(key) ~= "string" or allowedViewKeys[key] ~= true then
addError(errors, "unknown_battle_log_view_field", "$", "상세 전투 로그 View에 허용되지 않은 필드가 있습니다.")
end
end
if type(view.available) ~= "boolean" then
addError(errors, "invalid_battle_log_availability", "$.available", "상세 전투 로그 사용 가능 여부는 불리언이어야 합니다.")
end
if not isInteger(view.turnCount, 0) or view.turnCount > MAX_TURNS then
addError(errors, "invalid_battle_log_turn_count", "$.turnCount", "상세 전투 로그 턴 수가 올바르지 않습니다.")
end
if not isDenseArray(view.entries) then
addError(errors, "invalid_battle_log_entries", "$.entries", "상세 전투 로그 항목이 연속 배열이 아닙니다.")
else
if view.available == false and (view.turnCount ~= 0 or #view.entries ~= 0) then
addError(errors, "unavailable_battle_log_has_content", "$", "사용할 수 없는 상세 전투 로그에는 턴과 항목이 없어야 합니다.")
elseif view.available == true and (not isInteger(view.turnCount, 1) or #view.entries == 0) then
addError(errors, "available_battle_log_missing_content", "$", "사용 가능한 상세 전투 로그에는 턴과 항목이 필요합니다.")
end
local previousTurnNumber = 0
for index, entry in ipairs(view.entries) do
local path = "$.entries[" .. index .. "]"
if type(entry) ~= "table" or getmetatable(entry) ~= nil then
addError(errors, "invalid_battle_log_entry", path, "상세 전투 로그 항목이 일반 객체가 아닙니다.")
else
local allowedEntryKeys = {
turnNumber = true,
sequence = true,
type = true,
text = true,
label = true,
}
for key in pairs(entry) do
if type(key) ~= "string" or allowedEntryKeys[key] ~= true then
addError(errors, "unknown_battle_log_entry_field", path, "상세 전투 로그 항목에 허용되지 않은 필드가 있습니다.")
end
end
if not isInteger(entry.turnNumber, 1)
or (isInteger(view.turnCount, 0) and entry.turnNumber > view.turnCount) then
addError(errors, "invalid_battle_log_entry_turn", path .. ".turnNumber", "상세 전투 로그 항목의 턴 번호가 올바르지 않습니다.")
elseif entry.turnNumber < previousTurnNumber then
addError(errors, "unordered_battle_log_entry", path .. ".turnNumber", "상세 전투 로그 턴 순서가 역행합니다.")
else
previousTurnNumber = entry.turnNumber
end
if not isInteger(entry.sequence, 0) then
addError(errors, "invalid_battle_log_entry_sequence", path .. ".sequence", "상세 전투 로그 사건 순번이 올바르지 않습니다.")
end
if not isAsciiId(entry.type) then
addError(errors, "invalid_battle_log_entry_type", path .. ".type", "상세 전투 로그 사건 종류가 올바르지 않습니다.")
end
if type(entry.text) ~= "string" or entry.text == "" then
addError(errors, "invalid_battle_log_entry_text", path .. ".text", "상세 전투 로그 문장이 비어 있습니다.")
end
local expectedLabel = tostring(entry.turnNumber) .. "턴 · " .. tostring(entry.text)
if entry.label ~= expectedLabel then
addError(errors, "battle_log_entry_label_mismatch", path .. ".label", "상세 전투 로그 표시 문장이 턴 번호와 본문에 일치하지 않습니다.")
end
end
end
end
if #errors > 0 then return failure(errors) end
return success({ valid = true })
end
local function buildPublicView(history, staticInput)
local staticData = normalizeStaticData(staticInput)
local validation = validateHistory(history, nil, staticData)
if not validation.ok then return validation end
local turns = validation.history.turns
if #turns == 0 then
local emptyView = { available = false, turnCount = 0, entries = {} }
local emptyValidation = validatePublicView(emptyView)
if not emptyValidation.ok then return emptyValidation end
return success({ view = emptyView })
end
local entries = {}
local function append(turnNumber, sequence, eventType, text)
entries[#entries + 1] = {
turnNumber = turnNumber,
sequence = sequence,
type = eventType,
text = text,
label = tostring(turnNumber) .. "턴 · " .. text,
}
end
for _, entry in ipairs(turns) do
append(
entry.turnNumber,
0,
"turn_start",
"시작 — 은폐 " .. numberText(entry.start.stealth)
.. " / 저항 " .. numberText(entry.start.resistance)
.. " / 무드 " .. moodLabel(staticData, entry.start.mood)
)
local publicEvents = type(entry.publicResult) == "table" and entry.publicResult.events or {}
for publicIndex, event in ipairs(type(publicEvents) == "table" and publicEvents or {}) do
local payload = type(event) == "table" and event.payload or nil
local sequence = type(event) == "table" and event.sequence or publicIndex
local eventType = type(event) == "table" and event.type or "unknown"
local text = nil
if eventType == "card_declared" and type(payload) == "table" and isAsciiId(payload.cardId) then
text = "플레이어 카드 「" .. cardName(staticData, payload.cardId) .. "」 선언"
elseif eventType == "character_intent" and type(payload) == "table" and isAsciiId(payload.role) then
text = "상대 예고 역할 — " .. roleLabel(staticData, payload.role)
elseif eventType == "effect_applied" and type(payload) == "table" then
local effectLabel = EFFECT_TEXT[payload.op]
if effectLabel and isFinite(payload.amount) then
text = effectLabel .. " " .. numberText(payload.amount)
if isFinite(payload.before) and isFinite(payload.after) then
text = text .. " (" .. numberText(payload.before) .. " → " .. numberText(payload.after) .. ")"
end
elseif payload.op == "draw_cards" and isInteger(payload.drawnCount, 0) then
text = "추가 드로우 " .. tostring(payload.drawnCount) .. "장"
elseif payload.op == "skip_actions" then
text = "남은 행동 생략"
end
elseif eventType == "plan_changed" and type(payload) == "table" then
local planText = payload.action == "placed" and "계획 배치"
or payload.action == "discarded" and "계획 폐기"
or payload.action == "revealed" and "계획 공개"
or "계획 변화"
if isAsciiId(payload.cardId) then
planText = planText .. " — 「" .. cardName(staticData, payload.cardId) .. "」"
end
text = planText
elseif eventType == "actions_stopped" then
text = "남은 행동이 중단됨"
elseif eventType == "card_removed" and type(payload) == "table" and isAsciiId(payload.cardId) then
text = "카드 제거 — 「" .. cardName(staticData, payload.cardId) .. "」"
elseif eventType == "mood_evaluated" and type(payload) == "table"
and isAsciiId(payload.before) and isAsciiId(payload.after) then
text = "무드 판정 — " .. moodLabel(staticData, payload.before)
.. " → " .. moodLabel(staticData, payload.after)
elseif eventType == "outcome" and type(payload) == "table" then
text = payload.status == "victory" and "승리 확정" or "패배 확정"
elseif eventType == "session_ended" and type(payload) == "table" then
text = payload.status == "victory" and "전투 종료 — 승리" or "전투 종료 — 패배"
end
if text ~= nil then append(entry.turnNumber, sequence, eventType, text) end
end
for _, card in ipairs(entry.cards.player) do
if card.resolved then
append(
entry.turnNumber,
card.resolutionSequence or 9000,
"player_card_resolved",
"플레이어 해결 — 「" .. cardName(staticData, card.cardId)
.. "」 · " .. roleLabels(staticData, card.roles)
)
end
end
for _, card in ipairs(entry.cards.character) do
if card.resolved then
append(
entry.turnNumber,
card.resolutionSequence or 9100,
"character_card_resolved",
"상대 행동 해결 — " .. roleLabels(staticData, card.roles)
)
end
end
append(
entry.turnNumber,
99999,
"turn_end",
"종료 — 은폐 " .. numberText(entry.finish.stealth)
.. " / 저항 " .. numberText(entry.finish.resistance)
.. " / 무드 " .. moodLabel(staticData, entry.finish.mood)
)
end
local view = {
available = true,
turnCount = #turns,
entries = entries,
}
local viewValidation = validatePublicView(view)
if not viewValidation.ok then return viewValidation end
return success({ view = view })
end
local arguments = { ... }
if action == "empty" then
return success({ history = emptyHistory() })
elseif action == "validate" then
return validateHistory(arguments[1], arguments[2], arguments[3])
elseif action == "appendResolvedTurn" then
return appendResolvedTurn(arguments[1], arguments[2], arguments[3], arguments[4])
elseif action == "attachPublicResult" then
return attachPublicResult(arguments[1], arguments[2], arguments[3], arguments[4])
elseif action == "context" then
return buildContext(arguments[1])
elseif action == "validateContext" then
return validateContext(arguments[1])
elseif action == "buildPublicView" then
return buildPublicView(arguments[1], arguments[2])
elseif action == "validatePublicView" then
return validatePublicView(arguments[1])
end
return failure({ makeError("unknown_action", "$.action", "지원하지 않는 전투 이력 작업입니다: " .. tostring(action)) })
end)
#31battleRuntime.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
errors = errors,
}
end
local function pendingSuccess(pendingTurn, reused)
return {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
pendingTurn = pendingTurn,
turnId = pendingTurn.turnId,
reused = reused == true,
}
end
local function commitSuccess(state, turnId, applied)
return {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
state = state,
turnId = turnId,
applied = applied == true,
}
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and (minimum == nil or value >= minimum)
end
local function cloneData(value, path, active)
local valueType = type(value)
if valueType == "nil" or valueType == "string" or valueType == "boolean" then
return value, nil
end
if valueType == "number" then
if not isFinite(value) then
return nil, makeError("non_finite_number", path, "유한하지 않은 숫자는 런타임 트랜잭션에 저장할 수 없습니다.")
end
return value, nil
end
if valueType ~= "table" then
return nil, makeError("unsupported_type", path, "런타임 트랜잭션에 저장할 수 없는 자료형입니다.")
end
if getmetatable(value) ~= nil then
return nil, makeError("metatable_not_allowed", path, "런타임 트랜잭션 데이터에는 메타테이블을 사용할 수 없습니다.")
end
active = active or {}
if active[value] then
return nil, makeError("circular_reference", path, "순환 참조는 런타임 트랜잭션에 저장할 수 없습니다.")
end
active[value] = true
local numericCount = 0
local maximum = 0
local hasNumeric = false
local hasString = false
for key in pairs(value) do
if type(key) == "number" then
hasNumeric = true
numericCount = numericCount + 1
if not isInteger(key, 1) then
active[value] = nil
return nil, makeError("invalid_array_index", path, "배열 인덱스는 1 이상의 정수여야 합니다.")
end
maximum = math.max(maximum, key)
elseif type(key) == "string" then
hasString = true
else
active[value] = nil
return nil, makeError("invalid_object_key", path, "객체 키는 문자열이어야 합니다.")
end
end
if hasNumeric and hasString then
active[value] = nil
return nil, makeError("mixed_table", path, "숫자 인덱스와 문자열 키를 함께 사용할 수 없습니다.")
end
if hasNumeric and numericCount ~= maximum then
active[value] = nil
return nil, makeError("sparse_array", path, "배열 인덱스는 1부터 빈틈없이 이어져야 합니다.")
end
local copy = {}
if hasNumeric then
for index = 1, maximum do
local item, itemError = cloneData(value[index], path .. "[" .. index .. "]", active)
if itemError then
active[value] = nil
return nil, itemError
end
copy[index] = item
end
else
for key, item in pairs(value) do
local itemCopy, itemError = cloneData(item, path .. "." .. key, active)
if itemError then
active[value] = nil
return nil, itemError
end
copy[key] = itemCopy
end
end
active[value] = nil
return copy, nil
end
local function deepEqual(left, right, active)
if type(left) ~= type(right) then
return false
end
if type(left) ~= "table" then
return left == right
end
if getmetatable(left) ~= nil or getmetatable(right) ~= nil then
return false
end
active = active or {}
if active[left] ~= nil then
return active[left] == right
end
active[left] = right
for key, value in pairs(left) do
if not deepEqual(value, right[key], active) then
active[left] = nil
return false
end
end
for key in pairs(right) do
if left[key] == nil then
active[left] = nil
return false
end
end
active[left] = nil
return true
end
local function appendNestedErrors(target, prefix, report)
local nested = type(report) == "table" and report.errors or nil
if type(nested) ~= "table" or #nested == 0 then
target[#target + 1] = makeError("nested_operation_failed", prefix, "하위 런타임 작업이 실패했습니다.")
return
end
for _, item in ipairs(nested) do
local code = type(item) == "table" and item.code or nil
local path = type(item) == "table" and item.path or nil
local message = type(item) == "table" and item.message or nil
local nestedPath = prefix
if type(path) == "string" then
nestedPath = path:sub(1, 1) == "$" and (prefix .. path:sub(2)) or (prefix .. "." .. path)
end
target[#target + 1] = makeError(
type(code) == "string" and code or "nested_error",
nestedPath,
type(message) == "string" and message or "하위 런타임 작업이 실패했습니다."
)
end
end
local function callModule(moduleName, moduleAction, ...)
if type(runScript) ~= "function" then
return nil, {
makeError("runtime_unavailable", "$.runtime." .. moduleName, "스크립트 실행기를 찾을 수 없습니다."),
}
end
local ok, report = pcall(runScript, triggerId, moduleName, moduleAction, ...)
if not ok then
return nil, {
makeError("module_call_error", "$.runtime." .. moduleName, "하위 모듈 호출 중 오류가 발생했습니다."),
}
end
if type(report) ~= "table" then
return nil, {
makeError("invalid_module_result", "$.runtime." .. moduleName, "하위 모듈이 테이블 결과를 반환하지 않았습니다."),
}
end
if report.ok ~= true then
local errors = {}
appendNestedErrors(errors, "$.runtime." .. moduleName, report)
return nil, errors
end
return report, nil
end
local function validateAuthority(authorityState, staticData)
local report, errors = callModule(
"stateSchema",
"validateBattleState",
authorityState,
staticData
)
if errors then
return nil, errors
end
if report.referencesValidated ~= true then
return nil, {
makeError("static_references_not_validated", "$.staticData", "런타임 트랜잭션에는 전체 정적 데이터 검증이 필요합니다."),
}
end
return report.value or authorityState, nil
end
local function validateStoredPending(pendingTurn, staticData)
local shapeReport, shapeErrors = callModule(
"stateSchema",
"validatePendingTurn",
pendingTurn,
staticData
)
if shapeErrors then
return nil, shapeErrors
end
if shapeReport.referencesValidated ~= true then
return nil, {
makeError("static_references_not_validated", "$.staticData", "대기 트랜잭션 검증에는 전체 정적 데이터가 필요합니다."),
}
end
local replayReport, replayErrors = callModule(
"turnDraft",
"validateProjectionReceipt",
pendingTurn.beforeState,
staticData,
pendingTurn.projectionReceipt
)
if replayErrors then
return nil, replayErrors
end
if type(replayReport.projection) ~= "table" or type(replayReport.receipt) ~= "table" then
return nil, {
makeError("invalid_projection_replay", "$.projectionReceipt", "projection 영수증 재생 결과가 올바르지 않습니다."),
}
end
local pendingCopy, cloneError = cloneData(shapeReport.value or pendingTurn, "$.pendingTurn")
if cloneError then
return nil, { cloneError }
end
local presented, presentationErrors = callModule(
"turnPresentation",
"build",
pendingCopy,
staticData
)
if presentationErrors then
return nil, presentationErrors
end
if type(presented.lastTurn) ~= "table" or presented.lastTurn.available ~= true then
return nil, {
makeError(
"invalid_public_presentation",
"$.pendingTurn.turnResult.publicResult",
"공개 턴 표시를 생성하지 못했습니다."
),
}
end
return pendingCopy, nil
end
local function preparePending(authorityState, staticData, projection)
local validatedState, authorityErrors = validateAuthority(authorityState, staticData)
if authorityErrors then
return failure(authorityErrors)
end
if validatedState.status ~= "active" then
return failure({
makeError("prepare_requires_active", "$.authorityState.status", "active 전투에서만 턴을 판정할 수 있습니다."),
})
end
local receipt = validatedState.turnStartReceipt
if type(receipt) ~= "table" or type(receipt.turnId) ~= "string" then
return failure({
makeError("missing_turn_start_receipt", "$.authorityState.turnStartReceipt", "턴 판정 전 봉인된 turnStartReceipt가 필요합니다."),
})
end
local sealed, sealErrors = callModule(
"turnDraft",
"sealProjection",
validatedState,
staticData,
projection
)
if sealErrors then
return failure(sealErrors)
end
if type(sealed.projection) ~= "table" or type(sealed.receipt) ~= "table" then
return failure({
makeError("invalid_sealed_projection", "$.projection", "봉인된 projection 결과가 올바르지 않습니다."),
})
end
local resolved, resolveErrors = callModule(
"turnResolver",
"resolveTurn",
validatedState,
staticData,
sealed.projection,
{ turnId = receipt.turnId }
)
if resolveErrors then
return failure(resolveErrors)
end
local resolution = resolved.resolution
if type(resolution) ~= "table"
or resolution.battleId ~= validatedState.battleId
or resolution.turnId ~= receipt.turnId
or resolution.turnNumber ~= validatedState.turnNumber
or type(resolution.selectedCards) ~= "table"
or type(resolution.events) ~= "table"
or type(resolution.afterState) ~= "table" then
return failure({
makeError("invalid_turn_resolution", "$.turnResolution", "턴 해결 결과가 권위 상태와 연결되지 않습니다."),
})
end
local projected, projectErrors = callModule(
"turnEventProjector",
"projectTurn",
validatedState,
staticData,
resolution
)
if projectErrors then
return failure(projectErrors)
end
if type(projected.publicResult) ~= "table" or type(projected.llmEvent) ~= "table" then
return failure({
makeError("invalid_event_projection", "$.turnResolution", "턴 사건 투영 결과가 올바르지 않습니다."),
})
end
local historyReport, historyErrors = callModule(
"battleHistory",
"attachPublicResult",
resolution.afterState,
resolution.turnId,
projected.publicResult,
staticData
)
if historyErrors then
return failure(historyErrors)
end
if type(historyReport.state) ~= "table" then
return failure({
makeError("invalid_public_history_result", "$.runtime.battleHistory", "공개 전투 로그 연결 결과에 상태가 없습니다."),
})
end
resolution.afterState = historyReport.state
local constructed, constructErrors = callModule(
"stateSchema",
"newPendingTurn",
{
battleId = resolution.battleId,
turnId = resolution.turnId,
beforeState = validatedState,
projectionReceipt = sealed.receipt,
selectedCards = resolution.selectedCards,
turnResult = {
events = resolution.events,
publicResult = projected.publicResult,
llmEvent = projected.llmEvent,
},
afterState = resolution.afterState,
},
staticData
)
if constructErrors then
return failure(constructErrors)
end
local pendingTurn = constructed.value
if type(pendingTurn) ~= "table" then
return failure({
makeError("invalid_pending_result", "$.pendingTurn", "대기 트랜잭션 생성 결과가 올바르지 않습니다."),
})
end
local validatedPending, pendingErrors = validateStoredPending(pendingTurn, staticData)
if pendingErrors then
return failure(pendingErrors)
end
return pendingSuccess(validatedPending, false)
end
local function reusePending(authorityState, staticData, pendingTurn)
local validatedState, authorityErrors = validateAuthority(authorityState, staticData)
if authorityErrors then
return failure(authorityErrors)
end
local validatedPending, pendingErrors = validateStoredPending(pendingTurn, staticData)
if pendingErrors then
return failure(pendingErrors)
end
if validatedState.battleId ~= validatedPending.battleId then
return failure({
makeError("pending_battle_mismatch", "$.pendingTurn.battleId", "대기 트랜잭션이 현재 전투에 속하지 않습니다."),
})
end
if validatedState.lastCommittedTurnId == validatedPending.turnId then
return pendingSuccess(validatedPending, true)
end
if not deepEqual(validatedState, validatedPending.beforeState) then
return failure({
makeError("pending_authority_conflict", "$.authorityState", "현재 확정 상태가 대기 트랜잭션의 beforeState와 다릅니다."),
})
end
return pendingSuccess(validatedPending, true)
end
local function commitPending(authorityState, staticData, pendingTurn)
local validatedState, authorityErrors = validateAuthority(authorityState, staticData)
if authorityErrors then
return failure(authorityErrors)
end
local validatedPending, pendingErrors = validateStoredPending(pendingTurn, staticData)
if pendingErrors then
return failure(pendingErrors)
end
if validatedState.battleId ~= validatedPending.battleId then
return failure({
makeError("pending_battle_mismatch", "$.pendingTurn.battleId", "대기 트랜잭션이 현재 전투에 속하지 않습니다."),
})
end
if validatedState.lastCommittedTurnId == validatedPending.turnId then
local currentCopy, currentError = cloneData(validatedState, "$.authorityState")
if currentError then
return failure({ currentError })
end
return commitSuccess(currentCopy, validatedPending.turnId, false)
end
if not deepEqual(validatedState, validatedPending.beforeState) then
return failure({
makeError("pending_authority_conflict", "$.authorityState", "현재 확정 상태가 대기 트랜잭션의 beforeState와 다릅니다."),
})
end
local afterCopy, afterError = cloneData(validatedPending.afterState, "$.pendingTurn.afterState")
if afterError then
return failure({ afterError })
end
return commitSuccess(afterCopy, validatedPending.turnId, true)
end
local arguments = { ... }
if action == "preparePending" then
return preparePending(arguments[1], arguments[2], arguments[3])
elseif action == "reusePending" then
return reusePending(arguments[1], arguments[2], arguments[3])
elseif action == "commitPending" then
return commitPending(arguments[1], arguments[2], arguments[3])
end
return failure({
makeError("unknown_action", "$.action", "지원하지 않는 battleRuntime 작업입니다."),
})
end)
#32cardZones.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local MAX_PLAN_CAPACITY = 16
local VALID_OWNER = {
player = true,
character = true,
}
local ZONE_ORDER = {
"deck",
"hand",
"used",
"discard",
"removed",
"plan",
}
local VALID_ZONE = {}
for _, zone in ipairs(ZONE_ORDER) do
VALID_ZONE[zone] = true
end
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
errors = errors,
}
end
local function success(state, movedInstanceIds, drawnInstanceIds)
return {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
state = state,
movedInstanceIds = movedInstanceIds or {},
drawnInstanceIds = drawnInstanceIds or {},
}
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and (minimum == nil or value >= minimum)
end
local function isPlanCapacity(value)
return isInteger(value, 1) and value <= MAX_PLAN_CAPACITY
end
local function cloneFailure(code, path, message)
error(makeError(code, path, message), 0)
end
local function cloneJson(value, path, active)
local valueType = type(value)
if valueType == "nil" or valueType == "string" or valueType == "boolean" then
return value
end
if valueType == "number" then
if not isFinite(value) then
cloneFailure("non_finite_number", path, "NaN과 무한대는 카드 영역 상태에 사용할 수 없습니다.")
end
return value
end
if valueType ~= "table" then
cloneFailure(
"unsupported_type",
path,
"JSON 상태에 저장할 수 없는 자료형입니다: " .. valueType
)
end
if getmetatable(value) ~= nil then
cloneFailure("metatable_not_allowed", path, "상태 테이블에는 메타테이블을 사용할 수 없습니다.")
end
active = active or {}
if active[value] then
cloneFailure("circular_reference", path, "순환 참조가 있는 상태는 복제할 수 없습니다.")
end
active[value] = true
local numericCount = 0
local maximum = 0
local hasNumeric = false
local hasString = false
local stringKeys = {}
for key in pairs(value) do
if type(key) == "number" then
hasNumeric = true
numericCount = numericCount + 1
if not isInteger(key, 1) then
cloneFailure("invalid_array_index", path, "배열 인덱스는 1 이상의 정수여야 합니다.")
end
if key > maximum then
maximum = key
end
elseif type(key) == "string" then
hasString = true
table.insert(stringKeys, key)
else
cloneFailure("invalid_object_key", path, "객체 키는 문자열이어야 합니다.")
end
end
if hasNumeric and hasString then
cloneFailure("mixed_table", path, "숫자 인덱스와 문자열 키를 함께 사용할 수 없습니다.")
end
local copy = {}
if hasNumeric then
if numericCount ~= maximum then
cloneFailure("sparse_array", path, "배열 인덱스는 1부터 빈틈없이 이어져야 합니다.")
end
for index = 1, maximum do
copy[index] = cloneJson(value[index], path .. "[" .. index .. "]", active)
end
else
table.sort(stringKeys)
for _, key in ipairs(stringKeys) do
copy[key] = cloneJson(value[key], path .. "." .. key, active)
end
end
active[value] = nil
return copy
end
local function cloneState(state, rootPath)
rootPath = rootPath or "$"
if type(state) ~= "table" then
return nil, makeError("invalid_state", rootPath, "battleState가 테이블이 아닙니다.")
end
local ok, cloned = pcall(cloneJson, state, rootPath, {})
if not ok then
if type(cloned) == "table" and cloned.code and cloned.path and cloned.message then
return nil, cloned
end
return nil, makeError("clone_failed", rootPath, "battleState 복제에 실패했습니다: " .. tostring(cloned))
end
return cloned, nil
end
local function validateOwner(owner, path)
if not VALID_OWNER[owner] then
return makeError("invalid_owner", path, "소유자는 player 또는 character여야 합니다.")
end
return nil
end
local validatePlanLinks
local function validateCardInstances(state, rootPath)
rootPath = rootPath or "$"
local errors = {}
if type(state.cardInstances) ~= "table" then
table.insert(errors, makeError("invalid_card_instances", rootPath .. ".cardInstances", "카드 인스턴스 배열이 없습니다."))
return errors
end
local itemCount = 0
local maximum = 0
local arrayShapeValid = true
for key in pairs(state.cardInstances) do
if not isInteger(key, 1) then
arrayShapeValid = false
table.insert(errors, makeError(
"invalid_card_instances",
rootPath .. ".cardInstances",
"cardInstances는 1부터 이어지는 배열이어야 합니다."
))
break
end
itemCount = itemCount + 1
if key > maximum then
maximum = key
end
end
if arrayShapeValid and itemCount ~= maximum then
arrayShapeValid = false
table.insert(errors, makeError(
"sparse_card_instances",
rootPath .. ".cardInstances",
"cardInstances 배열에 빈 인덱스가 있습니다."
))
end
if not arrayShapeValid then
return errors
end
local seenIds = {}
local instancesById = {}
local positions = {}
local groupCounts = {}
for index, instance in ipairs(state.cardInstances) do
local path = rootPath .. ".cardInstances[" .. index .. "]"
if type(instance) ~= "table" then
table.insert(errors, makeError("invalid_card_instance", path, "카드 인스턴스가 테이블이 아닙니다."))
else
if type(instance.instanceId) ~= "string" or instance.instanceId == "" then
table.insert(errors, makeError("invalid_instance_id", path .. ".instanceId", "카드 인스턴스 ID가 없습니다."))
elseif seenIds[instance.instanceId] then
table.insert(errors, makeError("duplicate_instance_id", path .. ".instanceId", "카드 인스턴스 ID가 중복되었습니다."))
else
seenIds[instance.instanceId] = true
instancesById[instance.instanceId] = instance
end
if type(instance.cardId) ~= "string" or instance.cardId == "" then
table.insert(errors, makeError("invalid_card_id", path .. ".cardId", "카드 ID가 없습니다."))
end
local ownerError = validateOwner(instance.owner, path .. ".owner")
if ownerError then
table.insert(errors, ownerError)
end
if not VALID_ZONE[instance.zone] then
table.insert(errors, makeError("invalid_zone", path .. ".zone", "알 수 없는 카드 영역입니다."))
end
if not isInteger(instance.position, 1) then
table.insert(errors, makeError("invalid_position", path .. ".position", "카드 위치는 1 이상의 정수여야 합니다."))
elseif VALID_OWNER[instance.owner] and VALID_ZONE[instance.zone] then
local group = instance.owner .. ":" .. instance.zone
positions[group] = positions[group] or {}
groupCounts[group] = (groupCounts[group] or 0) + 1
if positions[group][instance.position] then
table.insert(errors, makeError(
"duplicate_position",
path .. ".position",
group .. " 영역의 position이 중복되었습니다."
))
end
positions[group][instance.position] = true
end
end
end
for group, groupPositions in pairs(positions) do
local uniqueCount = 0
local maximum = 0
for position in pairs(groupPositions) do
uniqueCount = uniqueCount + 1
if position > maximum then
maximum = position
end
end
if uniqueCount ~= maximum then
table.insert(errors, makeError(
"non_contiguous_positions",
rootPath .. ".cardInstances",
group .. " 영역의 position이 1부터 이어지지 않습니다."
))
end
end
for _, owner in ipairs({ "player", "character" }) do
local ownerState = state[owner]
if type(ownerState) ~= "table" then
table.insert(errors, makeError("missing_owner_state", rootPath .. "." .. owner, "소유자 전투 상태가 없습니다."))
elseif not isInteger(ownerState.maxHandSize, 1) then
table.insert(errors, makeError(
"invalid_hand_size",
rootPath .. "." .. owner .. ".maxHandSize",
"최대 손패는 1 이상의 정수여야 합니다."
))
else
local handCount = groupCounts[owner .. ":hand"] or 0
if handCount > ownerState.maxHandSize then
local overflowAllowed = false
if owner == "player"
and type(state.selection) == "table"
and type(state.selection.playerCardInstanceIds) == "table" then
local restoredSelectionCount = 0
local counted = {}
for _, instanceId in ipairs(state.selection.playerCardInstanceIds) do
local selected = instancesById[instanceId]
if selected
and selected.owner == "player"
and selected.zone == "hand"
and not counted[instanceId] then
counted[instanceId] = true
restoredSelectionCount = restoredSelectionCount + 1
end
end
overflowAllowed = restoredSelectionCount >= handCount - ownerState.maxHandSize
end
if not overflowAllowed then
table.insert(errors, makeError(
"hand_limit_exceeded",
rootPath .. ".cardInstances",
owner .. " 손패가 최대 손패를 초과했습니다."
))
end
end
end
end
return errors
end
local function prepareState(state)
local cloned, cloneError = cloneState(state)
if cloneError then
return nil, { cloneError }
end
local errors = validateCardInstances(cloned)
if #errors == 0 then
local planErrors = validatePlanLinks(cloned, "$")
for _, item in ipairs(planErrors) do
table.insert(errors, item)
end
end
if #errors > 0 then
return nil, errors
end
return cloned, nil
end
local function collectZone(state, owner, zone)
local values = {}
for index, instance in ipairs(state.cardInstances) do
if instance.owner == owner and instance.zone == zone then
table.insert(values, {
instance = instance,
sourceIndex = index,
})
end
end
table.sort(values, function(left, right)
if left.instance.position ~= right.instance.position then
return left.instance.position < right.instance.position
end
if left.instance.instanceId ~= right.instance.instanceId then
return left.instance.instanceId < right.instance.instanceId
end
return left.sourceIndex < right.sourceIndex
end)
return values
end
local function normalizeZone(state, owner, zone)
for position, entry in ipairs(collectZone(state, owner, zone)) do
entry.instance.position = position
end
end
local function normalizeAll(state)
for _, owner in ipairs({ "player", "character" }) do
for _, zone in ipairs(ZONE_ORDER) do
normalizeZone(state, owner, zone)
end
end
end
local function findInstance(state, instanceId)
for _, instance in ipairs(state.cardInstances) do
if instance.instanceId == instanceId then
return instance
end
end
return nil
end
local function nextPosition(state, owner, zone)
return #collectZone(state, owner, zone) + 1
end
local function denseArrayLength(value)
if type(value) ~= "table" or getmetatable(value) ~= nil then
return nil
end
local count = 0
local maximum = 0
for key in pairs(value) do
if not isInteger(key, 1) then
return nil
end
count = count + 1
if key > maximum then
maximum = key
end
end
if count ~= maximum then
return nil
end
return maximum
end
local function validatePositionContinuity(state, rootPath)
rootPath = rootPath or "$"
local errors = {}
local groups = {}
for index, instance in ipairs(state.cardInstances) do
if VALID_OWNER[instance.owner]
and VALID_ZONE[instance.zone]
and isInteger(instance.position, 1) then
local group = instance.owner .. ":" .. instance.zone
groups[group] = groups[group] or {}
if groups[group][instance.position] then
table.insert(errors, makeError(
"duplicate_position",
rootPath .. ".cardInstances[" .. index .. "].position",
group .. " 영역의 position이 중복되었습니다."
))
end
groups[group][instance.position] = true
end
end
for group, positions in pairs(groups) do
local count = 0
local maximum = 0
for position in pairs(positions) do
count = count + 1
if position > maximum then
maximum = position
end
end
if count ~= maximum then
table.insert(errors, makeError(
"non_contiguous_positions",
rootPath .. ".cardInstances",
group .. " 영역의 position이 1부터 이어지지 않습니다."
))
end
end
return errors
end
validatePlanLinks = function(state, rootPath)
rootPath = rootPath or "$"
local errors = {}
for _, side in ipairs({ "player", "character" }) do
local sidePath = rootPath .. "." .. side
local ownerState = state[side]
if type(ownerState) ~= "table" then
table.insert(errors, makeError("missing_owner_state", sidePath, "소유자 전투 상태가 없습니다."))
else
local slots = ownerState.planSlots
local slotsPath = sidePath .. ".planSlots"
local slotCount = denseArrayLength(slots)
local planInstances = collectZone(state, side, "plan")
if not isPlanCapacity(ownerState.planCapacity) then
table.insert(errors, makeError(
"invalid_plan_capacity",
sidePath .. ".planCapacity",
"계획 용량은 1 이상 16 이하의 정수여야 합니다."
))
end
if slotCount == nil then
table.insert(errors, makeError(
"invalid_plan_slots",
slotsPath,
"planSlots는 점유 계획만 담는 1부터 이어지는 배열이어야 합니다."
))
else
if isPlanCapacity(ownerState.planCapacity) and slotCount > ownerState.planCapacity then
table.insert(errors, makeError(
"plan_capacity_exceeded",
slotsPath,
"점유 계획 수가 계획 용량을 초과했습니다."
))
end
if #planInstances ~= slotCount then
table.insert(errors, makeError(
"plan_link_mismatch",
rootPath .. ".cardInstances",
"plan 영역과 planSlots 수가 일치하지 않습니다: " .. side
))
end
local seenInstanceIds = {}
for index = 1, slotCount do
local slot = slots[index]
local slotPath = slotsPath .. "[" .. index .. "]"
if type(slot) ~= "table" or getmetatable(slot) ~= nil then
table.insert(errors, makeError("invalid_plan_slot", slotPath, "계획 슬롯이 일반 테이블이 아닙니다."))
elseif slot.occupied ~= true then
table.insert(errors, makeError(
"invalid_plan_occupied",
slotPath .. ".occupied",
"planSlots에는 occupied = true인 점유 슬롯만 저장할 수 있습니다."
))
else
if type(slot.cardInstanceId) == "string" and slot.cardInstanceId ~= "" then
if seenInstanceIds[slot.cardInstanceId] then
table.insert(errors, makeError(
"duplicate_plan_slot",
slotPath .. ".cardInstanceId",
"같은 계획 카드 인스턴스가 planSlots에 중복되었습니다."
))
else
seenInstanceIds[slot.cardInstanceId] = true
end
end
local instance = findInstance(state, slot.cardInstanceId)
if not instance then
table.insert(errors, makeError("missing_plan_instance", slotPath .. ".cardInstanceId", "계획 카드 인스턴스를 찾을 수 없습니다."))
else
if instance.owner ~= side then
table.insert(errors, makeError("plan_owner_mismatch", slotPath .. ".cardInstanceId", "계획 슬롯과 카드 소유자가 다릅니다."))
end
if instance.zone ~= "plan" then
table.insert(errors, makeError("plan_zone_mismatch", slotPath .. ".cardInstanceId", "계획 카드의 zone이 plan이 아닙니다."))
end
if instance.cardId ~= slot.cardId then
table.insert(errors, makeError("plan_card_mismatch", slotPath .. ".cardId", "계획 슬롯과 카드 인스턴스의 cardId가 다릅니다."))
end
if instance.position ~= index then
table.insert(errors, makeError(
"plan_position_mismatch",
slotPath .. ".cardInstanceId",
"plan 영역 position은 planSlots 배열 인덱스와 일치해야 합니다."
))
end
end
if not planInstances[index]
or planInstances[index].instance.instanceId ~= slot.cardInstanceId then
table.insert(errors, makeError(
"plan_link_mismatch",
rootPath .. ".cardInstances",
"plan 영역 순서와 planSlots 배열 순서가 일치하지 않습니다: " .. side
))
end
if not isInteger(slot.placedTurn, 1) then
table.insert(errors, makeError("invalid_placed_turn", slotPath .. ".placedTurn", "계획 배치 턴은 1 이상의 정수여야 합니다."))
elseif isInteger(state.turnNumber, 1) and slot.placedTurn > state.turnNumber then
table.insert(errors, makeError("future_placed_turn", slotPath .. ".placedTurn", "계획 배치 턴은 현재 턴보다 클 수 없습니다."))
end
if slot.revealed ~= true and slot.revealed ~= false then
table.insert(errors, makeError("invalid_revealed", slotPath .. ".revealed", "revealed는 불리언이어야 합니다."))
end
if slot.effectChoiceId ~= nil
and (type(slot.effectChoiceId) ~= "string"
or string.match(slot.effectChoiceId, "^[a-z][a-z0-9_]*$") == nil) then
table.insert(errors, makeError("invalid_effect_choice_id", slotPath .. ".effectChoiceId", "계획 효과 선택지 ID가 올바르지 않습니다."))
end
if slot.durationIncludesPlacementTurn ~= nil
and type(slot.durationIncludesPlacementTurn) ~= "boolean" then
table.insert(errors, makeError(
"invalid_plan_duration_policy",
slotPath .. ".durationIncludesPlacementTurn",
"배치 턴 포함 여부는 불리언이어야 합니다."
))
elseif slot.durationIncludesPlacementTurn == true and slot.remainingTurns == nil then
table.insert(errors, makeError(
"plan_duration_policy_requires_duration",
slotPath .. ".durationIncludesPlacementTurn",
"배치 턴을 포함하는 계획에는 남은 지속시간이 필요합니다."
))
end
local hasLifetime = false
if slot.remainingTurns ~= nil then
hasLifetime = true
if not isInteger(slot.remainingTurns, 1) then
table.insert(errors, makeError("invalid_remaining_turns", slotPath .. ".remainingTurns", "점유된 계획의 남은 지속시간은 양의 정수여야 합니다."))
end
end
if slot.remainingCharges ~= nil then
hasLifetime = true
if not isInteger(slot.remainingCharges, 1) then
table.insert(errors, makeError("invalid_remaining_charges", slotPath .. ".remainingCharges", "점유된 계획의 남은 충전은 양의 정수여야 합니다."))
end
end
if not hasLifetime then
table.insert(errors, makeError("missing_plan_lifetime", slotPath, "점유된 계획에는 지속시간이나 충전이 필요합니다."))
end
end
end
end
end
end
return errors
end
local function appendErrors(target, source)
for _, item in ipairs(source or {}) do
table.insert(target, item)
end
end
local function syncPlanPositions(state, side)
local ownerState = state[side]
for index, slot in ipairs(ownerState.planSlots) do
local instance = findInstance(state, slot.cardInstanceId)
if instance then
instance.position = index
end
end
end
local function discardPlanAt(state, side, slotIndex)
local ownerState = state[side]
local slot = ownerState.planSlots[slotIndex]
local instance = findInstance(state, slot.cardInstanceId)
instance.zone = "discard"
instance.position = nextPosition(state, side, "discard")
table.remove(ownerState.planSlots, slotIndex)
syncPlanPositions(state, side)
return instance.instanceId
end
local function findPlanSlotIndex(state, side, instanceId)
for index, slot in ipairs(state[side].planSlots) do
if slot.cardInstanceId == instanceId then
return index
end
end
return nil
end
local function appendNestedErrors(errors, nested)
if type(nested) ~= "table" or #nested == 0 then
table.insert(errors, makeError("rng_failed", "$.rng", "결정적 셔플이 실패했습니다."))
return
end
for _, item in ipairs(nested) do
table.insert(errors, makeError(
"rng_" .. tostring(item.code or "failed"),
tostring(item.path or "$.rng"),
tostring(item.message or "결정적 셔플이 실패했습니다.")
))
end
end
local function shuffleIds(state, instanceIds)
local callOk, report = pcall(
runScript,
triggerId,
"deterministicRng",
"shuffle",
state.rng,
instanceIds
)
if not callOk then
return nil, { makeError("rng_call_failed", "$.rng", "결정적 셔플 호출에 실패했습니다: " .. tostring(report)) }
end
if type(report) ~= "table" or report.ok ~= true then
local errors = {}
appendNestedErrors(errors, type(report) == "table" and report.errors or nil)
return nil, errors
end
if type(report.value) ~= "table" or #report.value ~= #instanceIds then
return nil, { makeError("invalid_rng_result", "$.rng", "셔플 결과 배열의 길이가 올바르지 않습니다.") }
end
local expected = {}
for _, instanceId in ipairs(instanceIds) do
expected[instanceId] = (expected[instanceId] or 0) + 1
end
for index, instanceId in ipairs(report.value) do
if type(instanceId) ~= "string" or not expected[instanceId] or expected[instanceId] == 0 then
return nil, { makeError("invalid_rng_result", "$.rng.value[" .. index .. "]", "셔플 결과가 원본 카드 ID 순열이 아닙니다.") }
end
expected[instanceId] = expected[instanceId] - 1
end
local rngOk, rngCopy = pcall(cloneJson, report.rng, "$.rng", {})
if not rngOk or type(rngCopy) ~= "table" then
return nil, { makeError("invalid_rng_result", "$.rng", "셔플 결과의 RNG 상태가 올바르지 않습니다.") }
end
state.rng = rngCopy
local valueCopy = {}
for index, instanceId in ipairs(report.value) do
valueCopy[index] = instanceId
end
return valueCopy, nil
end
local function applyZoneOrder(state, owner, zone, instanceIds)
for position, instanceId in ipairs(instanceIds) do
local instance = findInstance(state, instanceId)
if not instance or instance.owner ~= owner then
return makeError("invalid_zone_order", "$.cardInstances", "카드 영역 순서를 적용할 인스턴스를 찾을 수 없습니다.")
end
instance.zone = zone
instance.position = position
end
return nil
end
local function shuffleDeck(state, owner)
local ownerError = validateOwner(owner, "$.owner")
if ownerError then
return failure({ ownerError })
end
local nextState, errors = prepareState(state)
if errors then
return failure(errors)
end
normalizeAll(nextState)
local instanceIds = {}
for _, entry in ipairs(collectZone(nextState, owner, "deck")) do
table.insert(instanceIds, entry.instance.instanceId)
end
local shuffled, shuffleErrors = shuffleIds(nextState, instanceIds)
if shuffleErrors then
return failure(shuffleErrors)
end
local orderError = applyZoneOrder(nextState, owner, "deck", shuffled)
if orderError then
return failure({ orderError })
end
normalizeAll(nextState)
return success(nextState, {}, {})
end
local function draw(state, owner, amount)
local errors = {}
local ownerError = validateOwner(owner, "$.owner")
if ownerError then
table.insert(errors, ownerError)
end
if not isInteger(amount, 0) then
table.insert(errors, makeError("invalid_draw_amount", "$.amount", "드로우 수량은 0 이상의 정수여야 합니다."))
end
if #errors > 0 then
return failure(errors)
end
local nextState, stateErrors = prepareState(state)
if stateErrors then
return failure(stateErrors)
end
normalizeAll(nextState)
local ownerState = nextState[owner]
if type(ownerState) ~= "table" then
return failure({ makeError("missing_owner_state", "$." .. owner, "소유자 전투 상태가 없습니다.") })
end
if not isInteger(ownerState.maxHandSize, 1) then
return failure({ makeError("invalid_hand_size", "$." .. owner .. ".maxHandSize", "최대 손패는 1 이상의 정수여야 합니다.") })
end
local handCount = #collectZone(nextState, owner, "hand")
local capacity = math.max(0, ownerState.maxHandSize - handCount)
local targetCount = math.min(amount, capacity)
local drawnInstanceIds = {}
while #drawnInstanceIds < targetCount do
local deck = collectZone(nextState, owner, "deck")
if #deck == 0 then
local discardIds = {}
for _, entry in ipairs(collectZone(nextState, owner, "discard")) do
table.insert(discardIds, entry.instance.instanceId)
end
if #discardIds == 0 then
break
end
local shuffled, shuffleErrors = shuffleIds(nextState, discardIds)
if shuffleErrors then
return failure(shuffleErrors)
end
local orderError = applyZoneOrder(nextState, owner, "deck", shuffled)
if orderError then
return failure({ orderError })
end
normalizeZone(nextState, owner, "discard")
normalizeZone(nextState, owner, "deck")
deck = collectZone(nextState, owner, "deck")
end
local drawn = deck[1].instance
drawn.zone = "hand"
drawn.position = nextPosition(nextState, owner, "hand")
table.insert(drawnInstanceIds, drawn.instanceId)
normalizeZone(nextState, owner, "deck")
normalizeZone(nextState, owner, "hand")
end
normalizeAll(nextState)
return success(nextState, {}, drawnInstanceIds)
end
local function moveHandToUsed(state, instanceId)
if type(instanceId) ~= "string" or instanceId == "" then
return failure({ makeError("invalid_instance_id", "$.instanceId", "카드 인스턴스 ID가 없습니다.") })
end
local nextState, errors = prepareState(state)
if errors then
return failure(errors)
end
normalizeAll(nextState)
local instance = findInstance(nextState, instanceId)
if not instance then
return failure({ makeError("instance_not_found", "$.instanceId", "카드 인스턴스를 찾을 수 없습니다.") })
end
if instance.zone ~= "hand" then
return failure({ makeError("invalid_source_zone", "$.instanceId", "used 영역으로 옮길 카드는 hand 영역에 있어야 합니다.") })
end
instance.zone = "used"
instance.position = nextPosition(nextState, instance.owner, "used")
normalizeAll(nextState)
return success(nextState, { instanceId }, {})
end
local function moveUsedToHand(state, instanceId)
if type(instanceId) ~= "string" or instanceId == "" then
return failure({ makeError("invalid_instance_id", "$.instanceId", "카드 인스턴스 ID가 없습니다.") })
end
local nextState, errors = prepareState(state)
if errors then
return failure(errors)
end
normalizeAll(nextState)
local instance = findInstance(nextState, instanceId)
if not instance then
return failure({ makeError("instance_not_found", "$.instanceId", "카드 인스턴스를 찾을 수 없습니다.") })
end
if instance.zone ~= "used" then
return failure({ makeError("invalid_source_zone", "$.instanceId", "hand 영역으로 복원할 카드는 used 영역에 있어야 합니다.") })
end
if instance.owner ~= "player" then
return failure({ makeError("invalid_restore_owner", "$.instanceId", "used → hand 구조 복원은 플레이어 등록 카드에만 사용할 수 있습니다.") })
end
local registered = false
for _, selectedId in ipairs(
type(nextState.selection) == "table"
and type(nextState.selection.playerCardInstanceIds) == "table"
and nextState.selection.playerCardInstanceIds
or {}
) do
if selectedId == instanceId then
registered = true
break
end
end
if not registered then
return failure({ makeError("restore_requires_selection", "$.instanceId", "등록 선택에 없는 used 카드는 손패로 복원할 수 없습니다.") })
end
local ownerState = nextState[instance.owner]
if type(ownerState) ~= "table" or not isInteger(ownerState.maxHandSize, 1) then
return failure({ makeError(
"invalid_hand_size",
"$." .. tostring(instance.owner) .. ".maxHandSize",
"최대 손패는 1 이상의 정수여야 합니다."
) })
end
instance.zone = "hand"
instance.position = nextPosition(nextState, instance.owner, "hand")
normalizeAll(nextState)
return success(nextState, { instanceId }, {})
end
local function moveToRemoved(state, instanceId)
if type(instanceId) ~= "string" or instanceId == "" then
return failure({ makeError("invalid_instance_id", "$.instanceId", "카드 인스턴스 ID가 없습니다.") })
end
local nextState, errors = prepareState(state)
if errors then
return failure(errors)
end
normalizeAll(nextState)
local instance = findInstance(nextState, instanceId)
if not instance then
return failure({ makeError("instance_not_found", "$.instanceId", "카드 인스턴스를 찾을 수 없습니다.") })
end
if instance.zone == "removed" then
return failure({ makeError("already_removed", "$.instanceId", "카드가 이미 removed 영역에 있습니다.") })
end
if instance.zone == "plan" then
return failure({ makeError("plan_slot_required", "$.instanceId", "plan 카드는 계획 슬롯 처리 없이 removed 영역으로 옮길 수 없습니다.") })
end
instance.zone = "removed"
instance.position = nextPosition(nextState, instance.owner, "removed")
normalizeAll(nextState)
return success(nextState, { instanceId }, {})
end
local function validatePlanSpec(planSpec)
local errors = {}
if type(planSpec) ~= "table" or getmetatable(planSpec) ~= nil then
return nil, {
makeError("invalid_plan_spec", "$.planSpec", "planSpec은 메타테이블 없는 테이블이어야 합니다."),
}
end
local allowed = {
durationTurns = true,
durationIncludesPlacementTurn = true,
charges = true,
revealed = true,
effectChoiceId = true,
}
for key in pairs(planSpec) do
if type(key) ~= "string" or not allowed[key] then
table.insert(errors, makeError(
"unknown_plan_spec_field",
"$.planSpec." .. tostring(key),
"planSpec에 허용되지 않은 필드가 있습니다."
))
end
end
if planSpec.durationTurns ~= nil and not isInteger(planSpec.durationTurns, 1) then
table.insert(errors, makeError(
"invalid_plan_duration",
"$.planSpec.durationTurns",
"계획 지속시간은 양의 정수여야 합니다."
))
end
if planSpec.durationIncludesPlacementTurn ~= nil
and type(planSpec.durationIncludesPlacementTurn) ~= "boolean" then
table.insert(errors, makeError(
"invalid_plan_duration_policy",
"$.planSpec.durationIncludesPlacementTurn",
"배치 턴 포함 여부는 불리언이어야 합니다."
))
elseif planSpec.durationIncludesPlacementTurn == true
and not isInteger(planSpec.durationTurns, 1) then
table.insert(errors, makeError(
"plan_duration_policy_requires_duration",
"$.planSpec.durationIncludesPlacementTurn",
"배치 턴을 포함하려면 양의 durationTurns가 필요합니다."
))
end
if planSpec.charges ~= nil and not isInteger(planSpec.charges, 1) then
table.insert(errors, makeError(
"invalid_plan_charges",
"$.planSpec.charges",
"계획 충전은 양의 정수여야 합니다."
))
end
if planSpec.durationTurns == nil and planSpec.charges == nil then
table.insert(errors, makeError(
"missing_plan_lifetime",
"$.planSpec",
"계획에는 durationTurns 또는 charges가 필요합니다."
))
end
if planSpec.revealed ~= nil and planSpec.revealed ~= true and planSpec.revealed ~= false then
table.insert(errors, makeError(
"invalid_plan_revealed",
"$.planSpec.revealed",
"revealed는 불리언이어야 합니다."
))
end
if planSpec.effectChoiceId ~= nil
and (type(planSpec.effectChoiceId) ~= "string"
or string.match(planSpec.effectChoiceId, "^[a-z][a-z0-9_]*$") == nil) then
table.insert(errors, makeError("invalid_effect_choice_id", "$.planSpec.effectChoiceId", "계획 효과 선택지 ID가 올바르지 않습니다."))
end
if #errors > 0 then
return nil, errors
end
return {
durationTurns = planSpec.durationTurns,
durationIncludesPlacementTurn = planSpec.durationIncludesPlacementTurn == true,
charges = planSpec.charges,
revealed = planSpec.revealed == true,
effectChoiceId = planSpec.effectChoiceId,
}, nil
end
local function placePlan(state, side, instanceId, planSpec)
local errors = {}
local sideError = validateOwner(side, "$.side")
if sideError then
table.insert(errors, sideError)
end
if type(instanceId) ~= "string" or instanceId == "" then
table.insert(errors, makeError("invalid_instance_id", "$.instanceId", "카드 인스턴스 ID가 없습니다."))
end
local normalizedSpec, specErrors = validatePlanSpec(planSpec)
appendErrors(errors, specErrors)
if #errors > 0 then
return failure(errors)
end
local nextState, stateErrors = prepareState(state)
if stateErrors then
return failure(stateErrors)
end
normalizeAll(nextState)
if not isInteger(nextState.turnNumber, 1) then
return failure({ makeError("invalid_turn_number", "$.turnNumber", "현재 턴은 1 이상의 정수여야 합니다.") })
end
local planErrors = validatePlanLinks(nextState, "$")
if #planErrors > 0 then
return failure(planErrors)
end
local instance = findInstance(nextState, instanceId)
if not instance then
return failure({ makeError("instance_not_found", "$.instanceId", "카드 인스턴스를 찾을 수 없습니다.") })
end
if instance.owner ~= side then
return failure({ makeError("plan_owner_mismatch", "$.instanceId", "계획 카드와 슬롯의 소유자가 다릅니다.") })
end
if instance.zone ~= "hand" and instance.zone ~= "used" then
return failure({ makeError("invalid_source_zone", "$.instanceId", "계획 카드는 hand 또는 used 영역에 있어야 합니다.") })
end
local movedInstanceIds = {}
local ownerState = nextState[side]
if #ownerState.planSlots >= ownerState.planCapacity then
table.insert(movedInstanceIds, discardPlanAt(nextState, side, 1))
end
instance.zone = "plan"
instance.position = #ownerState.planSlots + 1
local slot = {
occupied = true,
cardInstanceId = instance.instanceId,
cardId = instance.cardId,
placedTurn = nextState.turnNumber,
durationIncludesPlacementTurn = normalizedSpec.durationIncludesPlacementTurn,
revealed = normalizedSpec.revealed,
}
if normalizedSpec.durationTurns ~= nil then
slot.remainingTurns = normalizedSpec.durationTurns
end
if normalizedSpec.charges ~= nil then
slot.remainingCharges = normalizedSpec.charges
end
if normalizedSpec.effectChoiceId ~= nil then
slot.effectChoiceId = normalizedSpec.effectChoiceId
end
table.insert(ownerState.planSlots, slot)
table.insert(movedInstanceIds, instance.instanceId)
normalizeAll(nextState)
local outputErrors = validatePlanLinks(nextState, "$")
if #outputErrors > 0 then
return failure(outputErrors)
end
return success(nextState, movedInstanceIds, {})
end
local function consumePlanCharge(state, side, instanceId)
local errors = {}
local sideError = validateOwner(side, "$.side")
if sideError then
table.insert(errors, sideError)
end
if instanceId ~= nil and (type(instanceId) ~= "string" or instanceId == "") then
table.insert(errors, makeError("invalid_instance_id", "$.instanceId", "계획 카드 인스턴스 ID가 올바르지 않습니다."))
end
if #errors > 0 then
return failure(errors)
end
local nextState, stateErrors = prepareState(state)
if stateErrors then
return failure(stateErrors)
end
normalizeAll(nextState)
local planErrors = validatePlanLinks(nextState, "$")
if #planErrors > 0 then
return failure(planErrors)
end
local slots = nextState[side].planSlots
local slotIndex
if instanceId == nil then
if #slots == 0 then
return failure({ makeError("empty_plan_slots", "$." .. side .. ".planSlots", "발동할 계획이 없습니다.") })
end
if #slots > 1 then
return failure({ makeError(
"ambiguous_plan_slot",
"$.instanceId",
"점유 계획이 둘 이상이면 소비할 카드 인스턴스 ID를 지정해야 합니다."
) })
end
slotIndex = 1
instanceId = slots[1].cardInstanceId
else
slotIndex = findPlanSlotIndex(nextState, side, instanceId)
if slotIndex == nil then
return failure({ makeError(
"plan_instance_not_found",
"$.instanceId",
"해당 진영의 점유 계획에서 카드 인스턴스를 찾을 수 없습니다."
) })
end
end
local slot = slots[slotIndex]
slot.revealed = true
local movedInstanceIds = {}
if slot.remainingCharges ~= nil then
slot.remainingCharges = slot.remainingCharges - 1
if slot.remainingCharges == 0 then
table.insert(movedInstanceIds, discardPlanAt(nextState, side, slotIndex))
end
end
normalizeAll(nextState)
local outputErrors = validatePlanLinks(nextState, "$")
if #outputErrors > 0 then
return failure(outputErrors)
end
return success(nextState, movedInstanceIds, {})
end
local function setPlanCapacity(state, side, capacity)
local errors = {}
local sideError = validateOwner(side, "$.side")
if sideError then
table.insert(errors, sideError)
end
if not isPlanCapacity(capacity) then
table.insert(errors, makeError(
"invalid_plan_capacity",
"$.capacity",
"계획 용량은 1 이상 16 이하의 정수여야 합니다."
))
end
if #errors > 0 then
return failure(errors)
end
local nextState, stateErrors = prepareState(state)
if stateErrors then
return failure(stateErrors)
end
normalizeAll(nextState)
local ownerState = nextState[side]
ownerState.planCapacity = capacity
local movedInstanceIds = {}
while #ownerState.planSlots > capacity do
table.insert(movedInstanceIds, discardPlanAt(nextState, side, 1))
end
normalizeAll(nextState)
local outputErrors = validatePlanLinks(nextState, "$")
if #outputErrors > 0 then
return failure(outputErrors)
end
return success(nextState, movedInstanceIds, {})
end
local function endTurnCleanup(state)
local nextState, stateErrors = prepareState(state)
if stateErrors then
return failure(stateErrors)
end
normalizeAll(nextState)
if not isInteger(nextState.turnNumber, 1) then
return failure({ makeError("invalid_turn_number", "$.turnNumber", "현재 턴은 1 이상의 정수여야 합니다.") })
end
local planErrors = validatePlanLinks(nextState, "$")
if #planErrors > 0 then
return failure(planErrors)
end
local movedInstanceIds = {}
for _, side in ipairs({ "player", "character" }) do
for _, sourceZone in ipairs({ "used", "hand" }) do
local source = collectZone(nextState, side, sourceZone)
for _, entry in ipairs(source) do
entry.instance.zone = "discard"
entry.instance.position = nextPosition(nextState, side, "discard")
table.insert(movedInstanceIds, entry.instance.instanceId)
end
end
end
nextState.selection = {
playerCardInstanceIds = {},
}
nextState.characterIntent = {
cardInstanceIds = {},
}
nextState.turnStartReceipt = nil
for _, side in ipairs({ "player", "character" }) do
local survivors = {}
for _, slot in ipairs(nextState[side].planSlots) do
if slot.remainingTurns ~= nil
and (slot.placedTurn < nextState.turnNumber
or slot.durationIncludesPlacementTurn == true) then
slot.remainingTurns = slot.remainingTurns - 1
end
if slot.remainingTurns == 0 then
local instance = findInstance(nextState, slot.cardInstanceId)
instance.zone = "discard"
instance.position = nextPosition(nextState, side, "discard")
table.insert(movedInstanceIds, instance.instanceId)
else
table.insert(survivors, slot)
end
end
nextState[side].planSlots = survivors
syncPlanPositions(nextState, side)
end
normalizeAll(nextState)
local outputErrors = validatePlanLinks(nextState, "$")
if #outputErrors > 0 then
return failure(outputErrors)
end
return success(nextState, movedInstanceIds, {})
end
local function validateConservation(beforeState, afterState)
local beforeCopy, beforeCloneError = cloneState(beforeState, "$.beforeState")
if beforeCloneError then
return failure({ beforeCloneError })
end
local afterCopy, afterCloneError = cloneState(afterState, "$.afterState")
if afterCloneError then
return failure({ afterCloneError })
end
local errors = {}
appendErrors(errors, validateCardInstances(beforeCopy, "$.beforeState"))
appendErrors(errors, validateCardInstances(afterCopy, "$.afterState"))
if #errors > 0 then
return failure(errors)
end
appendErrors(errors, validatePositionContinuity(beforeCopy, "$.beforeState"))
appendErrors(errors, validatePositionContinuity(afterCopy, "$.afterState"))
appendErrors(errors, validatePlanLinks(beforeCopy, "$.beforeState"))
appendErrors(errors, validatePlanLinks(afterCopy, "$.afterState"))
local beforeById = {}
local afterById = {}
for _, instance in ipairs(beforeCopy.cardInstances) do
beforeById[instance.instanceId] = instance
end
for _, instance in ipairs(afterCopy.cardInstances) do
afterById[instance.instanceId] = instance
end
for instanceId, beforeInstance in pairs(beforeById) do
local afterInstance = afterById[instanceId]
if not afterInstance then
table.insert(errors, makeError(
"missing_card_instance",
"$.afterState.cardInstances",
"이후 상태에서 카드 인스턴스가 사라졌습니다: " .. instanceId
))
else
if beforeInstance.cardId ~= afterInstance.cardId then
table.insert(errors, makeError(
"card_id_changed",
"$.afterState.cardInstances",
"카드 인스턴스의 cardId가 변경되었습니다: " .. instanceId
))
end
if beforeInstance.owner ~= afterInstance.owner then
table.insert(errors, makeError(
"card_owner_changed",
"$.afterState.cardInstances",
"카드 인스턴스의 owner가 변경되었습니다: " .. instanceId
))
end
end
end
for instanceId in pairs(afterById) do
if not beforeById[instanceId] then
table.insert(errors, makeError(
"unexpected_card_instance",
"$.afterState.cardInstances",
"이후 상태에 새 카드 인스턴스가 생겼습니다: " .. instanceId
))
end
end
if #errors > 0 then
return failure(errors)
end
return success(afterCopy, {}, {})
end
local arguments = { ... }
local actions = {
shuffleDeck = shuffleDeck,
draw = draw,
moveHandToUsed = moveHandToUsed,
moveUsedToHand = moveUsedToHand,
moveToRemoved = moveToRemoved,
placePlan = placePlan,
consumePlanCharge = consumePlanCharge,
setPlanCapacity = setPlanCapacity,
endTurnCleanup = endTurnCleanup,
validateConservation = validateConservation,
}
local handler = actions[action]
if not handler then
return failure({
makeError(
"unknown_action",
"$.action",
"지원하지 않는 카드 영역 작업입니다: " .. tostring(action)
),
})
end
return handler(arguments[1], arguments[2], arguments[3], arguments[4])
end)
#33characterSelector.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local MAX_SAFE_INTEGER = 9007199254740991
local SCORE_SCALE = 3
local MOOD_POSITION_SCALE = 4
local MOOD_STEALTH_SCALE = 2
local MOOD_TOKEN_SCALE = 2
local MOOD_ANCHOR_SCALE = 2
local SCORE_OPERATIONS = {
recover_resistance = { scoreDirection = 1, stealthDirection = 0 },
lose_stealth = { scoreDirection = 1, stealthDirection = -1 },
damage_resistance = { scoreDirection = -1, stealthDirection = 0 },
recover_stealth = { scoreDirection = -1, stealthDirection = 1 },
}
local TOTAL_FIELD = {
recover_resistance = "recoverResistance",
lose_stealth = "loseStealth",
damage_resistance = "damageResistance",
recover_stealth = "recoverStealth",
}
local function makeError(code, path, message)
return { code = code, path = path, message = message }
end
local function failure(errors)
return { ok = false, schemaVersion = SCHEMA_VERSION, errors = errors }
end
local function success(state, intent, receipt)
return {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
state = state,
intent = intent,
receipt = receipt,
}
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and (minimum == nil or value >= minimum)
end
local function isSafeInteger(value, minimum)
return isInteger(value, minimum) and math.abs(value) <= MAX_SAFE_INTEGER
end
local function isDenseArray(value)
if type(value) ~= "table" or getmetatable(value) ~= nil then return false end
local count = 0
local maximum = 0
for key in pairs(value) do
if not isInteger(key, 1) then return false end
count = count + 1
if key > maximum then maximum = key end
end
return count == maximum
end
local function cloneData(value, path, active)
local valueType = type(value)
if valueType == "nil" or valueType == "string" or valueType == "boolean" then return value, nil end
if valueType == "number" then
if not isFinite(value) then
return nil, makeError("non_finite_number", path, "NaN과 무한대는 선택 상태에 사용할 수 없습니다.")
end
return value, nil
end
if valueType ~= "table" then
return nil, makeError("unsupported_type", path, "JSON 선택 상태에 저장할 수 없는 자료형입니다: " .. valueType)
end
if getmetatable(value) ~= nil then
return nil, makeError("metatable_not_allowed", path, "선택 상태에는 메타테이블을 사용할 수 없습니다.")
end
active = active or {}
if active[value] then return nil, makeError("circular_reference", path, "순환 참조가 있는 선택 상태는 복제할 수 없습니다.") end
active[value] = true
local copy = {}
for key, item in pairs(value) do
local keyCopy, keyError = cloneData(key, path .. ".<key>", active)
if keyError then active[value] = nil return nil, keyError end
local itemCopy, itemError = cloneData(item, path .. "." .. tostring(key), active)
if itemError then active[value] = nil return nil, itemError end
copy[keyCopy] = itemCopy
end
active[value] = nil
return copy, nil
end
local function deepEqual(left, right, seen)
if type(left) ~= type(right) then return false end
if type(left) ~= "table" then return left == right end
seen = seen or {}
if seen[left] == right then return true end
seen[left] = right
for key, value in pairs(left) do
if not deepEqual(value, right[key], seen) then return false end
end
for key in pairs(right) do
if left[key] == nil then return false end
end
return true
end
local function normalizeStaticData(staticData)
if type(staticData) == "table" and type(staticData.data) == "table" then return staticData.data end
return staticData
end
local function appendErrors(target, source)
if type(source) ~= "table" then
target[#target + 1] = makeError("invalid_nested_error", "$", "하위 모듈 오류 목록이 올바르지 않습니다.")
return
end
for _, item in ipairs(source) do
target[#target + 1] = {
code = tostring(type(item) == "table" and item.code or "nested_error"),
path = tostring(type(item) == "table" and item.path or "$"),
message = tostring(type(item) == "table" and item.message or "하위 모듈 작업이 실패했습니다."),
}
end
end
local function callModule(moduleName, moduleAction, ...)
if type(runScript) ~= "function" then
return nil, { makeError("runtime_unavailable", "$.runtime." .. moduleName, "스크립트 실행기를 찾을 수 없습니다.") }
end
local ok, report = pcall(runScript, triggerId, moduleName, moduleAction, ...)
if not ok then
return nil, { makeError("module_call_error", "$.runtime." .. moduleName, moduleName .. "." .. moduleAction .. " 실행에 실패했습니다: " .. tostring(report)) }
end
if type(report) ~= "table" then
return nil, { makeError("invalid_module_result", "$.runtime." .. moduleName, "하위 모듈이 테이블 결과를 반환하지 않았습니다.") }
end
if report.ok ~= true then
local errors = {}
appendErrors(errors, report.errors)
return nil, errors
end
return report, nil
end
local function countZone(state, owner, zone)
local count = 0
for _, instance in ipairs(type(state.cardInstances) == "table" and state.cardInstances or {}) do
if instance.owner == owner and instance.zone == zone then count = count + 1 end
end
return count
end
local function moodTokenSnapshot(state, staticData)
local snapshot = {}
local source = type(state) == "table" and type(state.character) == "table" and type(state.character.moodTokens) == "table" and state.character.moodTokens or {}
local moods = type(staticData) == "table" and type(staticData.registry) == "table" and type(staticData.registry.moods) == "table" and staticData.registry.moods or {}
for moodId in pairs(moods) do snapshot[moodId] = source[moodId] or 0 end
return snapshot
end
local function orderedCharacterHand(state)
local hand = {}
for sourceIndex, instance in ipairs(type(state.cardInstances) == "table" and state.cardInstances or {}) do
if instance.owner == "character" and instance.zone == "hand" then
hand[#hand + 1] = { instance = instance, sourceIndex = sourceIndex }
end
end
table.sort(hand, function(left, right)
if left.instance.position ~= right.instance.position then return left.instance.position < right.instance.position end
if left.instance.instanceId ~= right.instance.instanceId then return left.instance.instanceId < right.instance.instanceId end
return left.sourceIndex < right.sourceIndex
end)
return hand
end
local function buildHistoryContext(state)
if type(state) == "table" and type(state.historyContextOverride) == "table" then return state.historyContextOverride end
local report, errors = callModule("battleHistory", "context", type(state) == "table" and state.history or nil)
if errors or type(report) ~= "table" or type(report.context) ~= "table" then error("battleHistory.context failed", 0) end
return report.context
end
local function buildSelectionContext(state, staticData, hand)
local characterHand = {}
for index, entry in ipairs(hand) do
local instance = entry.instance
local card = type(staticData.cards) == "table" and staticData.cards[instance.cardId] or nil
characterHand[index] = {
instanceId = instance.instanceId,
cardId = instance.cardId,
role = type(card) == "table" and card.roles[1] or nil,
handPosition = instance.position,
}
end
return {
turnNumber = state.turnNumber,
history = buildHistoryContext(state),
player = { stealth = state.player.stealth, handCount = countZone(state, "player", "hand") },
character = {
resistance = state.character.resistance,
mood = state.character.mood,
moodTokens = moodTokenSnapshot(state, staticData),
},
characterHand = characterHand,
}
end
local function buildContext(state, staticData, card, instance, plan)
local context = {
turn = state.turnNumber,
phase = "character_selection",
mood = state.character.mood,
history = buildHistoryContext(state),
player = { stealth = state.player.stealth, handCount = countZone(state, "player", "hand") },
character = {
resistance = state.character.resistance,
moodTokens = moodTokenSnapshot(state, staticData),
publicRole = nil,
},
}
if card ~= nil and instance ~= nil then
context.card = { id = card.id, instanceId = instance.instanceId, owner = card.owner, roles = card.roles }
end
if plan ~= nil then
context.plan = {
cardId = plan.cardId,
cardInstanceId = plan.cardInstanceId,
side = plan.side,
revealed = false,
remainingCharges = plan.remainingCharges,
}
if plan.remainingTurns ~= nil then context.plan.remainingTurns = plan.remainingTurns end
end
return context
end
local function emptyTotals()
return { recoverResistance = 0, loseStealth = 0, damageResistance = 0, recoverStealth = 0 }
end
local function scoreCommands(commands, totals, path, moodCommands)
if not isDenseArray(commands) then
return nil, makeError("invalid_scoring_commands", path, "점수화할 효과 명령이 연속 배열이 아닙니다.")
end
local scoreDelta = 0
local stealthDelta = 0
for index, command in ipairs(commands) do
local commandPath = path .. "[" .. index .. "]"
local isMoodCommand = type(command) == "table"
and (command.op == "add_mood_token" or command.op == "remove_mood_token" or command.op == "force_mood")
if isMoodCommand then
moodCommands[#moodCommands + 1] = command
else
local operation = type(command) == "table" and SCORE_OPERATIONS[command.op] or nil
if not operation then
return nil, makeError("unsupported_character_score_op", commandPath .. ".op", "캐릭터 선택 점수로 해석할 수 없는 효과 작업입니다: " .. tostring(type(command) == "table" and command.op or nil))
end
if not isFinite(command.amount) or command.amount < 0 then
return nil, makeError("invalid_character_score_amount", commandPath .. ".amount", "선택 점수 효과 수치가 올바르지 않습니다.")
end
local field = TOTAL_FIELD[command.op]
totals[field] = totals[field] + command.amount
scoreDelta = scoreDelta + operation.scoreDirection * command.amount
stealthDelta = stealthDelta + operation.stealthDirection * command.amount
end
end
return { scoreDelta = scoreDelta, stealthDelta = stealthDelta }, nil
end
local function moodOrder(staticData)
local ordered = {}
local indexById = {}
local moods = type(staticData) == "table" and type(staticData.registry) == "table" and staticData.registry.moods or nil
if type(moods) ~= "table" then return nil, nil end
local count = 0
for moodId, mood in pairs(moods) do
if type(mood) ~= "table" or not isInteger(mood.order, 1) or ordered[mood.order] ~= nil then return nil, nil end
ordered[mood.order] = moodId
indexById[moodId] = mood.order
count = count + 1
end
for i = 1, count do if ordered[i] == nil then return nil, nil end end
return ordered, indexById
end
local function tokenPressure(count)
if count <= 0 then return 0 end
if count == 1 then return 1 end
if count == 2 then return 4 end
return 7 + (count - 3) * 2
end
local function moodStrategicUtility(state, staticData, report)
local resolution = type(report) == "table" and report.resolution or nil
if type(resolution) ~= "table" or not isFinite(resolution.stealthDelta) or type(resolution.moodTokens) ~= "table" then return nil end
local ordered, indexById = moodOrder(staticData)
if ordered == nil then return nil end
local currentOrder = indexById[state.character.mood]
local finalOrder = indexById[resolution.mood]
if currentOrder == nil or finalOrder == nil then return nil end
local center = (#ordered + 1) / 2
local utility = (center - finalOrder) * MOOD_POSITION_SCALE
utility = utility - resolution.stealthDelta * MOOD_STEALTH_SCALE
local currentAnchorSign = center - currentOrder
local escapePressure = 0
if currentAnchorSign > 0 then
for order = currentOrder + 1, #ordered do
escapePressure = math.max(escapePressure, tokenPressure(resolution.moodTokens[ordered[order]] or 0))
end
end
for order, moodId in ipairs(ordered) do
local count = resolution.moodTokens[moodId] or 0
local pressure = tokenPressure(count)
if order < currentOrder then
utility = utility + (currentOrder - order) * pressure * MOOD_TOKEN_SCALE
elseif order > currentOrder then
utility = utility - (order - currentOrder) * pressure * MOOD_TOKEN_SCALE
elseif pressure > 0 then
if currentAnchorSign > 0 then
utility = utility + math.min(pressure, escapePressure) * MOOD_ANCHOR_SCALE
elseif currentAnchorSign < 0 then
utility = utility + currentAnchorSign * pressure
end
end
end
return utility
end
local function scoreMoodCommands(state, staticData, commands)
if #commands == 0 then return 0, nil end
local spec = {
turnNumber = state.turnNumber,
mood = state.character.mood,
moodTokens = moodTokenSnapshot(state, staticData),
forcedMoodRequests = {},
}
local baseline, baselineErrors = callModule("effectEngine", "projectMood", staticData, spec)
if baselineErrors then return nil, baselineErrors end
spec.commands = commands
local projected, projectedErrors = callModule("effectEngine", "projectMood", staticData, spec)
if projectedErrors then return nil, projectedErrors end
local baselineValue = moodStrategicUtility(state, staticData, baseline)
local projectedValue = moodStrategicUtility(state, staticData, projected)
if not isSafeInteger(baselineValue) or not isSafeInteger(projectedValue) then
return nil, { makeError("invalid_mood_score", "$.runtime.effectEngine.projectMood", "무드 템포 점수가 안전한 정수가 아닙니다.") }
end
return projectedValue - baselineValue, nil
end
local function scoreCandidate(state, staticData, card, instance)
if card.canPlay ~= nil then
return nil, { makeError("unsupported_character_can_play_selection", "$.staticData.cards." .. card.id .. ".canPlay", "캐릭터 canPlay 카드의 사전 선택 정책은 아직 확정되지 않았습니다.") }
end
if card.base.stealthCost ~= 0 or card.base.resistanceDamage ~= 0 then
return nil, { makeError("unsupported_character_base_values", "$.staticData.cards." .. card.id .. ".base", "캐릭터 카드의 기본 비용과 기본 저항 피해는 선택 점수 버전 1에서 0이어야 합니다.") }
end
local totals = emptyTotals()
local score = 0
local stealthDelta = 0
local moodScore = 0
local immediateMoodCommands = {}
local context = buildContext(state, staticData, card, instance, nil)
local cardReport, cardErrors = callModule("effectEngine", "evaluateCardResolve", staticData, card.id, context)
if cardErrors then return nil, cardErrors end
local cardScore, cardScoreError = scoreCommands(cardReport.commands, totals, "$.staticData.cards." .. card.id .. ".resolve.commands", immediateMoodCommands)
if cardScoreError then return nil, { cardScoreError } end
score = score + cardScore.scoreDelta
stealthDelta = stealthDelta + cardScore.stealthDelta
local moodReport, moodErrors = callModule("effectEngine", "evaluateMoodEffect", staticData, card.id, state.character.mood, context)
if moodErrors then return nil, moodErrors end
local moodEffectScore, moodScoreError = scoreCommands(moodReport.commands, totals, "$.staticData.cards." .. card.id .. ".moodEffects." .. state.character.mood .. ".commands", immediateMoodCommands)
if moodScoreError then return nil, { moodScoreError } end
score = score + moodEffectScore.scoreDelta
stealthDelta = stealthDelta + moodEffectScore.stealthDelta
local immediateMoodScore, immediateMoodErrors = scoreMoodCommands(state, staticData, immediateMoodCommands)
if immediateMoodErrors then return nil, immediateMoodErrors end
moodScore = moodScore + immediateMoodScore
local planChargesEvaluated = 0
if card.cardType == "plan" then
local plan = type(card.mechanismData) == "table" and card.mechanismData.plan or nil
local assumption = type(plan) == "table" and plan.selectionAssumption or nil
local event = type(assumption) == "table" and assumption.event or nil
if type(plan) ~= "table"
or not isInteger(plan.charges, 1)
or type(assumption) ~= "table"
or assumption.chargePolicy ~= "all"
or type(event) ~= "table"
or type(event.type) ~= "string"
or (event.side ~= "player" and event.side ~= "character") then
return nil, { makeError("invalid_plan_selection_assumption", "$.staticData.cards." .. card.id .. ".mechanismData.plan.selectionAssumption", "캐릭터 계획 선택 가정이 정적 데이터 계약과 맞지 않습니다.") }
end
for chargeIndex = 1, plan.charges do
local remainingCharges = plan.charges - chargeIndex + 1
local planContext = buildContext(state, staticData, nil, nil, {
cardId = card.id,
cardInstanceId = instance.instanceId,
side = "character",
remainingTurns = plan.durationTurns,
remainingCharges = remainingCharges,
})
local triggerReport, triggerErrors = callModule("effectEngine", "evaluateTrigger", staticData, plan, planContext, event)
if triggerErrors then return nil, triggerErrors end
if triggerReport.matched ~= true then
return nil, { makeError("plan_selection_assumption_not_matched", "$.staticData.cards." .. card.id .. ".mechanismData.plan.selectionAssumption.event", "명시한 계획 선택 가정이 계획 발동 조건과 일치하지 않습니다.") }
end
local planMoodCommands = {}
local planScore, planScoreError = scoreCommands(triggerReport.commands, totals, "$.staticData.cards." .. card.id .. ".mechanismData.plan.resolve.commands[charge=" .. chargeIndex .. "]", planMoodCommands)
if planScoreError then return nil, { planScoreError } end
score = score + planScore.scoreDelta
stealthDelta = stealthDelta + planScore.stealthDelta
local chargeMoodScore, chargeMoodErrors = scoreMoodCommands(state, staticData, planMoodCommands)
if chargeMoodErrors then return nil, chargeMoodErrors end
moodScore = moodScore + chargeMoodScore
planChargesEvaluated = planChargesEvaluated + 1
end
end
score = score * SCORE_SCALE + moodScore
local projectedStealth = state.player.stealth + stealthDelta
if not isSafeInteger(score) or not isFinite(projectedStealth) then
return nil, { makeError("non_finite_character_score", "$.staticData.cards." .. card.id, "캐릭터 선택 점수가 안전한 정수 범위를 벗어났습니다.") }
end
return {
instanceId = instance.instanceId,
cardId = card.id,
role = card.roles[1],
handPosition = instance.position,
score = score,
projectedPlayerStealth = projectedStealth,
lethal = projectedStealth <= 0,
weight = 0,
moodScore = moodScore,
totals = totals,
planChargesEvaluated = planChargesEvaluated,
}, nil
end
local function validateReceipt(staticInput, receipt, expectedState)
local staticData = normalizeStaticData(staticInput)
if type(receipt) ~= "table" or getmetatable(receipt) ~= nil then
return failure({ makeError("invalid_character_selection_receipt", "$.receipt", "캐릭터 선택 영수증이 일반 테이블이 아닙니다.") })
end
local context = receipt.selectionContext
if type(context) ~= "table" or getmetatable(context) ~= nil then
return failure({ makeError("invalid_selection_context", "$.receipt.selectionContext", "선택 시점 컨텍스트가 일반 테이블이 아닙니다.") })
end
if expectedState ~= nil then
local expectedContext = buildSelectionContext(expectedState, staticData, orderedCharacterHand(expectedState))
if not deepEqual(context, expectedContext) then
return failure({ makeError("selection_context_state_mismatch", "$.receipt.selectionContext", "선택 영수증 컨텍스트가 실제 선택 직전 상태와 일치하지 않습니다.") })
end
end
if not isInteger(context.turnNumber, 1)
or type(context.player) ~= "table"
or not isFinite(context.player.stealth)
or not isInteger(context.player.handCount, 0)
or type(context.character) ~= "table"
or not isFinite(context.character.resistance)
or type(context.character.mood) ~= "string"
or type(context.character.moodTokens) ~= "table"
or not isDenseArray(context.characterHand)
or not isDenseArray(receipt.candidates) then
return failure({ makeError("invalid_selection_context", "$.receipt.selectionContext", "선택 시점 컨텍스트 필드가 올바르지 않습니다.") })
end
if #context.characterHand ~= #receipt.candidates then
return failure({ makeError("selection_candidate_count_mismatch", "$.receipt.candidates", "선택 시점 손패와 후보 수가 다릅니다.") })
end
local syntheticState = {
turnNumber = context.turnNumber,
historyContextOverride = context.history,
player = { stealth = context.player.stealth },
character = {
resistance = context.character.resistance,
mood = context.character.mood,
moodTokens = moodTokenSnapshot({ character = context.character }, staticData),
},
cardInstances = {},
}
for index = 1, context.player.handCount do
syntheticState.cardInstances[index] = { owner = "player", zone = "hand" }
end
for index, handEntry in ipairs(context.characterHand) do
local candidate = receipt.candidates[index]
local card = type(staticData.cards) == "table" and staticData.cards[handEntry.cardId] or nil
if type(card) ~= "table" or card.owner ~= "character" or card.roles[1] ~= handEntry.role then
return failure({ makeError("selection_hand_static_mismatch", "$.receipt.selectionContext.characterHand[" .. index .. "]", "선택 시점 손패가 정적 캐릭터 카드와 일치하지 않습니다.") })
end
local instance = {
instanceId = handEntry.instanceId,
cardId = handEntry.cardId,
owner = "character",
zone = "hand",
position = handEntry.handPosition,
}
local expected, replayErrors = scoreCandidate(syntheticState, staticData, card, instance)
if replayErrors then return failure(replayErrors) end
if candidate.instanceId ~= expected.instanceId
or candidate.cardId ~= expected.cardId
or candidate.role ~= expected.role
or candidate.handPosition ~= expected.handPosition
or candidate.score ~= expected.score
or candidate.moodScore ~= expected.moodScore
or candidate.projectedPlayerStealth ~= expected.projectedPlayerStealth
or candidate.lethal ~= expected.lethal
or candidate.planChargesEvaluated ~= expected.planChargesEvaluated
or not deepEqual(candidate.totals, expected.totals) then
return failure({ makeError("selection_candidate_replay_mismatch", "$.receipt.candidates[" .. index .. "]", "후보 감사 값이 정적 카드 효과의 선택 시점 재평가 결과와 다릅니다.") })
end
end
return { ok = true, schemaVersion = SCHEMA_VERSION, errors = {}, valid = true }
end
local function selectIntent(state, staticInput)
local staticData = normalizeStaticData(staticInput)
local validation, validationErrors = callModule("stateSchema", "validateBattleState", state, staticData)
if validationErrors then return failure(validationErrors) end
if validation.referencesValidated ~= true then
return failure({ makeError("static_references_not_validated", "$.staticData", "캐릭터 선택에는 전체 정적 데이터 참조 검증이 필요합니다.") })
end
if state.status ~= "active" then return failure({ makeError("battle_not_active", "$.state.status", "진행 중인 전투에서만 캐릭터 의도를 선택할 수 있습니다.") }) end
if type(state.characterIntent) ~= "table"
or type(state.characterIntent.cardInstanceIds) ~= "table"
or #state.characterIntent.cardInstanceIds > 0
or state.characterIntent.publicRole ~= nil then
return failure({ makeError("character_intent_already_selected", "$.state.characterIntent", "이미 캐릭터 의도가 있는 상태를 다시 선택할 수 없습니다.") })
end
local hand = orderedCharacterHand(state)
local candidates = {}
for _, entry in ipairs(hand) do
local instance = entry.instance
local card = staticData.cards[instance.cardId]
if type(card) ~= "table" or card.owner ~= "character" then
return failure({ makeError("invalid_character_hand_card", "$.state.cardInstances", "캐릭터 손패의 카드 정의가 올바르지 않습니다: " .. tostring(instance.cardId)) })
end
if card.cardType == "chain" then
return failure({ makeError("unsupported_character_chain_selection", "$.staticData.cards." .. card.id .. ".cardType", "캐릭터 연계 카드 선택 순서와 장수 정책은 아직 지원하지 않습니다.") })
end
local candidate, candidateErrors = scoreCandidate(state, staticData, card, instance)
if candidateErrors then return failure(candidateErrors) end
candidates[#candidates + 1] = candidate
end
local rngBefore, rngBeforeError = cloneData(state.rng, "$.state.rng")
if rngBeforeError then return failure({ rngBeforeError }) end
local nextState, stateCloneError = cloneData(state, "$.state")
if stateCloneError then return failure({ stateCloneError }) end
local receipt = {
schemaVersion = SCHEMA_VERSION,
kind = "characterIntentSelection",
battleId = state.battleId,
turnNumber = state.turnNumber,
characterId = state.character.characterId,
selectionContext = buildSelectionContext(state, staticData, hand),
candidates = candidates,
weightedPoolInstanceIds = {},
lethalPriorityApplied = false,
rngBefore = rngBefore,
}
if #candidates == 0 then
nextState.characterIntent = { cardInstanceIds = {} }
local rngAfter, rngAfterError = cloneData(nextState.rng, "$.state.rng")
if rngAfterError then return failure({ rngAfterError }) end
receipt.rngAfter = rngAfter
receipt.draw = { kind = "pass" }
local outputValidation, outputErrors = callModule("stateSchema", "validateBattleState", nextState, staticData)
if outputErrors then return failure(outputErrors) end
if outputValidation.referencesValidated ~= true then
return failure({ makeError("output_references_not_validated", "$.state", "선택 결과의 정적 참조를 검증하지 못했습니다.") })
end
local intentCopy, intentCopyError = cloneData(nextState.characterIntent, "$.intent")
if intentCopyError then return failure({ intentCopyError }) end
return success(nextState, intentCopy, receipt)
end
local pool = candidates
local lethal = {}
for _, candidate in ipairs(candidates) do if candidate.lethal then lethal[#lethal + 1] = candidate end end
if #lethal > 0 then
pool = lethal
receipt.lethalPriorityApplied = true
end
local minimumScore = pool[1].score
for _, candidate in ipairs(pool) do
receipt.weightedPoolInstanceIds[#receipt.weightedPoolInstanceIds + 1] = candidate.instanceId
if candidate.score < minimumScore then minimumScore = candidate.score end
end
local weightOffset = minimumScore <= 0 and (1 - minimumScore) or 0
if not isSafeInteger(weightOffset, 0) then
return failure({ makeError("invalid_character_weight_offset", "$.candidates", "점수 가중치 평행이동 값을 안전한 정수로 만들 수 없습니다.") })
end
receipt.weightOffset = weightOffset
local totalWeight = 0
for _, candidate in ipairs(pool) do
candidate.weight = candidate.score + weightOffset
if candidate.weight < 1 then candidate.weight = 1 end
if not isSafeInteger(candidate.weight, 1) or not isSafeInteger(totalWeight + candidate.weight, 0) then
return failure({ makeError("invalid_character_selection_weight", "$.candidates", "후보 가중치 합이 안전한 정수 범위를 벗어났습니다.") })
end
totalWeight = totalWeight + candidate.weight
end
local selected
if #pool == 1 then
selected = pool[1]
receipt.draw = { kind = "single", totalWeight = totalWeight }
else
local rngReport, rngErrors = callModule("deterministicRng", "nextInteger", state.rng, 1, totalWeight)
if rngErrors then return failure(rngErrors) end
local roll = rngReport.value
local cumulative = 0
for _, candidate in ipairs(pool) do
cumulative = cumulative + candidate.weight
if roll <= cumulative then selected = candidate break end
end
if selected == nil then return failure({ makeError("invalid_weighted_character_result", "$.rng", "결정적 가중 추첨 결과를 후보에 대응할 수 없습니다.") }) end
local rngCopy, rngCopyError = cloneData(rngReport.rng, "$.rng")
if rngCopyError then return failure({ rngCopyError }) end
nextState.rng = rngCopy
receipt.draw = { kind = "weighted", totalWeight = totalWeight, roll = roll }
end
nextState.characterIntent = { cardInstanceIds = { selected.instanceId }, publicRole = selected.role }
receipt.selectedInstanceId = selected.instanceId
receipt.selectedCardId = selected.cardId
receipt.publicRole = selected.role
local rngAfter, rngAfterError = cloneData(nextState.rng, "$.state.rng")
if rngAfterError then return failure({ rngAfterError }) end
receipt.rngAfter = rngAfter
local outputValidation, outputErrors = callModule("stateSchema", "validateBattleState", nextState, staticData)
if outputErrors then return failure(outputErrors) end
if outputValidation.referencesValidated ~= true then
return failure({ makeError("output_references_not_validated", "$.state", "선택 결과의 정적 참조를 검증하지 못했습니다.") })
end
local intentCopy, intentCopyError = cloneData(nextState.characterIntent, "$.intent")
if intentCopyError then return failure({ intentCopyError }) end
return success(nextState, intentCopy, receipt)
end
local arguments = { ... }
local actions = { selectIntent = selectIntent, validateReceipt = validateReceipt }
local handler = actions[action]
if not handler then return failure({ makeError("unknown_action", "$.action", "지원하지 않는 캐릭터 선택 작업입니다: " .. tostring(action)) }) end
return handler(arguments[1], arguments[2], arguments[3])
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
wireFormat = WIRE_FORMAT,
errors = errors,
}
end
local function isFinite(value)
return value == value
and value ~= math.huge
and value ~= -math.huge
end
local function objectPath(path, key)
if string.match(key, "^[A-Za-z_][A-Za-z0-9_]*$") then
return path .. "." .. key
end
return path .. "[" .. string.format("%q", key) .. "]"
end
local function inspectTable(value, path, errors)
local numericKeys = {}
local stringKeys = {}
local hasNumeric = false
local hasString = false
local hasInvalid = false
local maximum = 0
for key in pairs(value) do
local keyType = type(key)
if keyType == "number" then
hasNumeric = true
table.insert(numericKeys, key)
if not isFinite(key) or key < 1 or key % 1 ~= 0 then
hasInvalid = true
table.insert(
errors,
makeError(
"invalid_array_index",
path,
"배열 인덱스는 1 이상의 유한한 정수여야 합니다: " .. tostring(key)
)
)
elseif key > maximum then
maximum = key
end
elseif keyType == "string" then
hasString = true
table.insert(stringKeys, key)
else
hasInvalid = true
table.insert(
errors,
makeError(
"invalid_object_key",
path,
"객체 키는 문자열이어야 합니다: " .. keyType
)
)
end
end
if hasNumeric and hasString then
hasInvalid = true
table.insert(
errors,
makeError(
"mixed_table",
path,
"숫자 인덱스와 문자열 키를 같은 테이블에서 함께 사용할 수 없습니다."
)
)
end
if hasInvalid then
return nil
end
if hasNumeric then
if #numericKeys ~= maximum then
table.insert(
errors,
makeError(
"sparse_array",
path,
"배열 인덱스는 1부터 빈틈없이 이어져야 합니다."
)
)
return nil
end
return "array", maximum
end
if hasString then
table.sort(stringKeys)
return "object", stringKeys
end
-- Lua의 빈 테이블은 형상을 구분할 수 없으므로 이 브리지에서는 빈 배열이다.
return "array", 0
end
local function validateJsonSafe(root)
local errors = {}
local active = {}
local function visit(value, path)
local valueType = type(value)
if valueType == "string" or valueType == "boolean" then
return
end
if valueType == "number" then
if not isFinite(value) then
table.insert(
errors,
makeError("non_finite_number", path, "숫자는 NaN이나 무한대일 수 없습니다.")
)
end
return
end
if valueType ~= "table" then
table.insert(
errors,
makeError(
"unsupported_type",
path,
"JSON View에 저장할 수 없는 자료형입니다: " .. valueType
)
)
return
end
if getmetatable(value) ~= nil then
table.insert(
errors,
makeError("metatable_not_allowed", path, "View 테이블에는 메타테이블을 사용할 수 없습니다.")
)
return
end
if active[value] then
table.insert(
errors,
makeError(
"circular_reference",
path,
"순환 참조가 있는 테이블은 직렬화할 수 없습니다."
)
)
return
end
active[value] = true
local kind, shape = inspectTable(value, path, errors)
if kind == "array" then
for index = 1, shape do
visit(value[index], path .. "[" .. index .. "]")
end
elseif kind == "object" then
for _, key in ipairs(shape) do
visit(value[key], objectPath(path, key))
end
end
active[value] = nil
end
if type(root) ~= "table" then
table.insert(
errors,
makeError("invalid_view_root", "$", "View의 최상위 값은 테이블이어야 합니다.")
)
else
visit(root, "$")
end
return errors
end
local function appendSchemaErrors(target, schemaResult, config)
if type(schemaResult) == "table" and type(schemaResult.errors) == "table" then
for index, schemaError in ipairs(schemaResult.errors) do
if type(schemaError) == "table" then
table.insert(
target,
makeError(
tostring(schemaError.code or config.errorCode),
tostring(schemaError.path or "$"),
tostring(schemaError.message or config.errorMessage)
)
)
else
table.insert(
target,
makeError(
config.errorCode,
"$",
config.errorMessage .. " 오류 " .. index .. ": " .. tostring(schemaError)
)
)
end
end
end
if #target == 0 then
table.insert(
target,
makeError(
config.errorCode,
"$",
config.validatorMessage
)
)
end
end
local function validateView(viewName, view)
local config = SUPPORTED_VIEWS[viewName]
if config == nil then
return failure({
makeError(
"unsupported_view",
"$",
"지원하지 않는 View입니다: " .. tostring(viewName)
),
})
end
if config.canonicalOnly then
return failure({
makeError(
"internal_view",
"$",
"내부 전용 View는 공식 builder의 canonical 경로에서만 게시할 수 있습니다."
),
})
end
local errors = validateJsonSafe(view)
if #errors > 0 then
return failure(errors)
end
if type(runScript) ~= "function" then
return failure({
makeError(
"view_validator_unavailable",
"$.runtime." .. config.moduleName,
"View 스키마 검증기를 호출할 수 없습니다."
),
})
end
local callOk, schemaResult = pcall(
runScript,
triggerId,
config.moduleName,
config.action,
view
)
if not callOk then
return failure({
makeError(
"view_validator_call_failed",
"$.runtime." .. config.moduleName,
"View 스키마 검증기 호출에 실패했습니다: " .. tostring(schemaResult)
),
})
end
local schemaPassed = schemaResult == true
or (type(schemaResult) == "table" and schemaResult.ok == true)
if not schemaPassed then
appendSchemaErrors(errors, schemaResult, config)
return failure(errors)
end
return {
ok = true,
wireFormat = WIRE_FORMAT,
}
end
local function encodeJsonString(value, protectCbsSyntax)
local parts = { '"' }
for index = 1, #value do
local byte = string.byte(value, index)
local character = string.char(byte)
if byte == 34 then
table.insert(parts, '\\"')
elseif byte == 92 then
table.insert(parts, "\\\\")
elseif byte == 8 then
table.insert(parts, "\\b")
elseif byte == 12 then
table.insert(parts, "\\f")
elseif byte == 10 then
table.insert(parts, "\\n")
elseif byte == 13 then
table.insert(parts, "\\r")
elseif byte == 9 then
table.insert(parts, "\\t")
elseif byte < 32 then
table.insert(parts, string.format("\\u%04X", byte))
elseif protectCbsSyntax and character == ":" then
table.insert(parts, "\\u003A")
elseif protectCbsSyntax and character == "{" then
table.insert(parts, "\\u007B")
elseif protectCbsSyntax and character == "}" then
table.insert(parts, "\\u007D")
else
table.insert(parts, character)
end
end
table.insert(parts, '"')
return table.concat(parts)
end
local function encodeNumber(value)
-- IEEE-754 double 값을 JSON으로 왕복할 수 있는 유효 자릿수를 유지한다.
local encoded = string.format("%.17g", value)
if encoded == "-0" then
return "0"
end
return encoded
end
local HTML_TEXT_ENTITIES = {
["&"] = "&",
["<"] = "<",
[">"] = ">",
['"'] = """,
["'"] = "'",
["{"] = "{",
["}"] = "}",
["("] = "(",
[")"] = ")",
[":"] = ":",
}
local function escapeHtmlText(value)
local escaped = string.gsub(value, "[&<>\"'{}():]", function(character)
return HTML_TEXT_ENTITIES[character]
end)
-- RisuAI는 Markdown 렌더링 뒤 U+E9B8..U+E9BF를 자체 escape
-- 문자로 복원한다. 원본 표시 문자열의 같은 private-use 문자가
-- 그 경계에 소비되지 않도록 브라우저 단계까지 숫자 엔티티로 둔다.
for offset = 0, 7 do
local character = string.char(238, 166, 184 + offset)
local entity = "&#" .. tostring(59832 + offset) .. ";"
escaped = string.gsub(escaped, character, entity)
end
return escaped
end
local encodeTable
local function encodeScalar(value)
local valueType = type(value)
if valueType == "string" then
-- battleView wire is consumed inside an HTML/CBS template. Encode
-- display text as entities before JSON quoting so allowed HTML tags
-- cannot change the template structure and a decoded CBS result
-- cannot introduce a fresh CBS directive or :: sequence.
return encodeJsonString(escapeHtmlText(value), true)
elseif valueType == "number" then
return encodeNumber(value)
elseif valueType == "boolean" then
return value and "true" or "false"
elseif valueType == "nil" then
return "null"
end
error("validated View contained an unsupported scalar: " .. valueType)
end
local function encodeChild(value)
if type(value) == "table" then
-- 중첩 노드는 자신의 JSON을 문자열로 한 층 감싸 CBS가 한 단계씩 읽게 한다.
return encodeJsonString(encodeTable(value), true)
end
return encodeScalar(value)
end
encodeTable = function(value)
local classificationErrors = {}
local kind, shape = inspectTable(value, "$", classificationErrors)
if not kind then
error("validated View table shape changed before encoding")
end
local parts = {}
if kind == "array" then
for index = 1, shape do
table.insert(parts, encodeChild(value[index]))
end
return "[" .. table.concat(parts, ",") .. "]"
end
for _, key in ipairs(shape) do
table.insert(parts, encodeJsonString(key, true) .. ":" .. encodeChild(value[key]))
end
return "{" .. table.concat(parts, ",") .. "}"
end
local function encodeValidatedView(viewName, view)
local ok, encoded = pcall(encodeTable, view)
if not ok then
return failure({
makeError("encode_failed", "$", "View 직렬화에 실패했습니다: " .. tostring(encoded)),
})
end
return {
ok = true,
wireFormat = WIRE_FORMAT,
encoded = encoded,
bytes = #encoded,
}
end
local function encodeView(viewName, view)
local validation = validateView(viewName, view)
if not validation.ok then
return validation
end
return encodeValidatedView(viewName, view)
end
local function authorizeCanonical(permit, viewName)
if type(permit) ~= "function" then
return false
end
local ok, allowed = pcall(permit, "dataBridgeCanonicalV1", viewName)
return ok and allowed == true
end
local function encodeCanonicalView(viewName, view, permit)
if SUPPORTED_VIEWS[viewName] == nil then
return failure({
makeError("unsupported_view", "$", "지원하지 않는 View입니다: " .. tostring(viewName)),
})
end
if not authorizeCanonical(permit, viewName) then
return failure({
makeError("internal_action_denied", "$.action", "검증된 View 전용 내부 작업에 접근할 수 없습니다."),
})
end
-- 이 경로는 같은 controller transaction에서 공식 View builder가
-- 이미 schema allowlist를 검증한 값만 받는다. JSON-safe 검사는
-- 유지해 함수, 메타테이블, 순환 참조가 bridge 밖으로 나가지 않게 한다.
local errors = validateJsonSafe(view)
if #errors > 0 then
return failure(errors)
end
return encodeValidatedView(viewName, view)
end
local function publishView(viewName, view)
local result = encodeView(viewName, view)
if not result.ok then
return result
end
local writeOk, writeError = pcall(function()
return HostCompat.writeChatVar(triggerId, viewName, result.encoded)
end)
if not writeOk then
return failure({
makeError(
"publish_failed",
"$",
"View 채팅 변수 저장에 실패했습니다: " .. tostring(writeError)
),
})
end
return result
end
local function publishCanonicalView(viewName, view, permit)
local result = encodeCanonicalView(viewName, view, permit)
if not result.ok then
return result
end
local writeOk, writeError = pcall(function()
return HostCompat.writeChatVar(triggerId, viewName, result.encoded)
end)
if not writeOk then
return failure({
makeError(
"publish_failed",
"$",
"View 채팅 변수 저장에 실패했습니다: " .. tostring(writeError)
),
})
end
return result
end
local arguments = { ... }
local viewName = arguments[1]
local view = arguments[2]
if action == "_encodeCanonical" then
return encodeCanonicalView(viewName, view, arguments[3])
elseif action == "_publishCanonical" then
return publishCanonicalView(viewName, view, arguments[3])
end
local actions = {
validate = validateView,
encode = encodeView,
publish = publishView,
}
local handler = actions[action]
if not handler then
return failure({
makeError(
"unknown_action",
"$",
"지원하지 않는 데이터 브리지 작업입니다: " .. tostring(action)
),
})
end
return handler(viewName, view)
end)
#35deterministicRng.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local ALGORITHM = "park_miller_schrage_v1"
local MODULUS = 2147483647
local MULTIPLIER = 16807
local QUOTIENT = 127773
local REMAINDER = 2836
local SOURCE_SIZE = MODULUS - 1
local MAX_SAFE_INTEGER = 9007199254740991
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
algorithm = ALGORITHM,
errors = errors,
}
end
local function success(value, rng)
local response = {
ok = true,
schemaVersion = SCHEMA_VERSION,
algorithm = ALGORITHM,
errors = {},
value = value,
}
if rng ~= nil then
response.rng = rng
end
return response
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isSafeInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and math.abs(value) <= MAX_SAFE_INTEGER
and (minimum == nil or value >= minimum)
end
local function cloneRng(rng)
return {
seed = rng.seed,
cursor = rng.cursor,
}
end
local function validateRng(rng)
local errors = {}
if type(rng) ~= "table" then
table.insert(errors, makeError("invalid_rng", "$.rng", "rng는 테이블이어야 합니다."))
return errors
end
if getmetatable(rng) ~= nil then
table.insert(errors, makeError("rng_metatable_not_allowed", "$.rng", "rng에는 메타테이블을 사용할 수 없습니다."))
return errors
end
for key in pairs(rng) do
if key ~= "seed" and key ~= "cursor" then
table.insert(
errors,
makeError("unknown_rng_field", "$.rng." .. tostring(key), "rng에 허용되지 않은 필드가 있습니다.")
)
end
end
if not isSafeInteger(rng.seed, 0) then
table.insert(
errors,
makeError(
"invalid_rng_seed",
"$.rng.seed",
"seed는 0 이상이고 IEEE-754 안전 범위 안의 정수여야 합니다."
)
)
end
if not isSafeInteger(rng.cursor, 0) then
table.insert(
errors,
makeError(
"invalid_rng_cursor",
"$.rng.cursor",
"cursor는 0 이상이고 IEEE-754 안전 범위 안의 정수여야 합니다."
)
)
end
return errors
end
local function normalizeSeed(seed)
-- 외부 seed는 그대로 보존하고 생성기 내부 상태만 1..M-1로 정규화한다.
local normalized = seed % MODULUS
if normalized == 0 then
return 1
end
return normalized
end
local function parkMillerStep(value)
-- Schrage 분해를 사용해 32비트 범위 안에서 A * value mod M을 계산한다.
local high = math.floor(value / QUOTIENT)
local low = value - high * QUOTIENT
local nextValue = MULTIPLIER * low - REMAINDER * high
if nextValue <= 0 then
nextValue = nextValue + MODULUS
end
return nextValue
end
local function multiplyModulo(left, right)
-- 모든 중간 덧셈을 2*M 미만으로 유지하는 정확한 double-and-add 곱셈이다.
local result = 0
while right > 0 do
if right % 2 == 1 then
result = result + left
if result >= MODULUS then
result = result - MODULUS
end
end
right = math.floor(right / 2)
if right > 0 then
left = left + left
if left >= MODULUS then
left = left - MODULUS
end
end
end
return result
end
local function powerModulo(base, exponent)
local result = 1
while exponent > 0 do
if exponent % 2 == 1 then
result = multiplyModulo(result, base)
end
exponent = math.floor(exponent / 2)
if exponent > 0 then
base = multiplyModulo(base, base)
end
end
return result
end
local function stateAtCursor(rng)
if rng.cursor == 0 then
return normalizeSeed(rng.seed)
end
local jump = powerModulo(MULTIPLIER, rng.cursor)
return multiplyModulo(normalizeSeed(rng.seed), jump)
end
local function nextRaw(rng)
if rng.cursor >= MAX_SAFE_INTEGER then
return nil, makeError(
"rng_cursor_exhausted",
"$.rng.cursor",
"cursor를 안전하게 증가시킬 수 있는 범위를 모두 사용했습니다."
)
end
local raw = parkMillerStep(stateAtCursor(rng))
return raw, {
seed = rng.seed,
cursor = rng.cursor + 1,
}
end
local function validateRange(minimum, maximum)
local errors = {}
if not isSafeInteger(minimum) then
table.insert(
errors,
makeError("invalid_range_minimum", "$.minimum", "minimum은 IEEE-754 안전 범위 안의 정수여야 합니다.")
)
end
if not isSafeInteger(maximum) then
table.insert(
errors,
makeError("invalid_range_maximum", "$.maximum", "maximum은 IEEE-754 안전 범위 안의 정수여야 합니다.")
)
end
if #errors > 0 then
return errors
end
if minimum > maximum then
table.insert(errors, makeError("invalid_range_order", "$.maximum", "maximum은 minimum보다 작을 수 없습니다."))
return errors
end
if maximum - minimum >= SOURCE_SIZE then
table.insert(
errors,
makeError(
"range_too_wide",
"$.maximum",
"한 번의 Park-Miller 표본으로 만들 수 있는 정수 범위를 초과했습니다."
)
)
end
return errors
end
local function generateInteger(rng, minimum, maximum)
local span = maximum - minimum + 1
local acceptedSize = SOURCE_SIZE - (SOURCE_SIZE % span)
local currentRng = cloneRng(rng)
while true do
local raw, nextStateOrError = nextRaw(currentRng)
if raw == nil then
return nil, nil, nextStateOrError
end
currentRng = nextStateOrError
local zeroBased = raw - 1
if zeroBased < acceptedSize then
return minimum + (zeroBased % span), currentRng, nil
end
end
end
local function validateAction(rng)
local errors = validateRng(rng)
if #errors > 0 then
return failure(errors)
end
return success(cloneRng(rng), cloneRng(rng))
end
local function nextInteger(rng, minimum, maximum)
local errors = validateRng(rng)
local rangeErrors = validateRange(minimum, maximum)
for _, item in ipairs(rangeErrors) do
table.insert(errors, item)
end
if #errors > 0 then
return failure(errors)
end
local value, nextRng, generationError = generateInteger(rng, minimum, maximum)
if generationError ~= nil then
return failure({ generationError })
end
return success(value, nextRng)
end
local function inspectArray(array)
local errors = {}
if type(array) ~= "table" then
table.insert(errors, makeError("invalid_array", "$.array", "shuffle 대상은 배열 테이블이어야 합니다."))
return errors, nil
end
if getmetatable(array) ~= nil then
table.insert(errors, makeError("array_metatable_not_allowed", "$.array", "배열에는 메타테이블을 사용할 수 없습니다."))
return errors, nil
end
local count = 0
local maximum = 0
for key in pairs(array) do
if not isSafeInteger(key, 1) then
table.insert(
errors,
makeError("invalid_array_index", "$.array", "배열 인덱스는 1 이상의 연속된 정수여야 합니다.")
)
return errors, nil
end
count = count + 1
if key > maximum then
maximum = key
end
end
if count ~= maximum then
table.insert(errors, makeError("sparse_array", "$.array", "배열 인덱스는 1부터 빈틈없이 이어져야 합니다."))
return errors, nil
end
if maximum > SOURCE_SIZE then
table.insert(
errors,
makeError("array_too_large", "$.array", "한 번의 Park-Miller 표본으로 섞을 수 있는 배열 길이를 초과했습니다.")
)
return errors, nil
end
return errors, maximum
end
local function nextIntegers(rng, ranges)
local errors = validateRng(rng)
local arrayErrors, length = inspectArray(ranges)
for _, item in ipairs(arrayErrors) do
item.path = string.gsub(item.path, "^%$%.array", "$.ranges")
table.insert(errors, item)
end
if length ~= nil then
for index = 1, length do
local range = ranges[index]
local path = "$.ranges[" .. index .. "]"
if type(range) ~= "table" then
table.insert(errors, makeError("invalid_range", path, "범위 항목은 테이블이어야 합니다."))
elseif getmetatable(range) ~= nil then
table.insert(errors, makeError("range_metatable_not_allowed", path, "범위 항목에는 메타테이블을 사용할 수 없습니다."))
else
for key in pairs(range) do
if key ~= "minimum" and key ~= "maximum" then
table.insert(errors, makeError(
"unknown_range_field",
path .. "." .. tostring(key),
"범위 항목에 허용되지 않은 필드가 있습니다."
))
end
end
local rangeErrors = validateRange(range.minimum, range.maximum)
for _, item in ipairs(rangeErrors) do
item.path = path .. string.sub(item.path, 2)
table.insert(errors, item)
end
end
end
end
if #errors > 0 then
return failure(errors)
end
-- generateInteger를 기존 nextInteger와 동일한 순서로 호출한다.
-- batch는 Lua 모듈 경계만 합치며 RNG 소비 의미는 바꾸지 않는다.
local values = {}
local currentRng = cloneRng(rng)
for index = 1, length do
local range = ranges[index]
local value, nextRng, generationError = generateInteger(
currentRng,
range.minimum,
range.maximum
)
if generationError ~= nil then
return failure({ generationError })
end
values[index] = value
currentRng = nextRng
end
return success(values, currentRng)
end
local function shuffle(rng, array)
local errors = validateRng(rng)
local arrayErrors, length = inspectArray(array)
for _, item in ipairs(arrayErrors) do
table.insert(errors, item)
end
if #errors > 0 then
return failure(errors)
end
local shuffled = {}
for index = 1, length do
shuffled[index] = array[index]
end
local currentRng = cloneRng(rng)
for index = length, 2, -1 do
local swapIndex, nextRng, generationError = generateInteger(currentRng, 1, index)
if generationError ~= nil then
return failure({ generationError })
end
currentRng = nextRng
shuffled[index], shuffled[swapIndex] = shuffled[swapIndex], shuffled[index]
end
return success(shuffled, currentRng)
end
local arguments = { ... }
local actions = {
validate = validateAction,
nextInteger = nextInteger,
nextIntegers = nextIntegers,
shuffle = shuffle,
}
local handler = actions[action]
if not handler then
return failure({
makeError(
"unknown_action",
"$",
"지원하지 않는 결정적 RNG 작업입니다: " .. tostring(action)
),
})
end
return handler(arguments[1], arguments[2], arguments[3])
end)
#36effectEngine.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
errors = errors,
}
end
local function success(commands, cardId)
return {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
commands = commands or {},
cardId = cardId,
}
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and (minimum == nil or value >= minimum)
end
local function isDenseArray(value)
if type(value) ~= "table" or getmetatable(value) ~= nil then
return false
end
local count = 0
local maximum = 0
for key in pairs(value) do
if not isInteger(key, 1) then
return false
end
count = count + 1
if key > maximum then
maximum = key
end
end
return count == maximum
end
local function normalizeStaticData(staticData)
if type(staticData) == "table" and type(staticData.data) == "table" then
return staticData.data
end
return staticData
end
local function findInstance(state, instanceId)
if type(state) ~= "table" or type(state.cardInstances) ~= "table" then
return nil
end
for _, instance in ipairs(state.cardInstances) do
if type(instance) == "table" and instance.instanceId == instanceId then
return instance
end
end
return nil
end
local function checkAllowedKeys(value, allowed, path, errors)
if type(value) ~= "table" then
return
end
for key in pairs(value) do
if type(key) ~= "string" or not allowed[key] then
table.insert(errors, makeError(
"unexpected_field",
path .. "." .. tostring(key),
"선택 단계 효과에 허용되지 않은 필드가 있습니다."
))
end
end
end
local function evaluateSelectionPreview(staticData, state, instanceId)
staticData = normalizeStaticData(staticData)
local errors = {}
if type(staticData) ~= "table"
or type(staticData.cards) ~= "table"
or type(staticData.registry) ~= "table" then
table.insert(errors, makeError(
"invalid_static_data",
"$.staticData",
"선택 단계 효과에는 전체 정적 데이터가 필요합니다."
))
end
if type(state) ~= "table" or type(state.cardInstances) ~= "table" then
table.insert(errors, makeError(
"invalid_state",
"$.state",
"선택 단계 전투 상태가 올바르지 않습니다."
))
elseif state.status ~= "active" then
table.insert(errors, makeError(
"battle_not_active",
"$.state.status",
"진행 중인 전투에서만 선택 단계 효과를 평가할 수 있습니다."
))
end
if type(instanceId) ~= "string" or instanceId == "" then
table.insert(errors, makeError(
"invalid_instance_id",
"$.instanceId",
"카드 인스턴스 ID가 없습니다."
))
end
if #errors > 0 then
return failure(errors)
end
local instance = findInstance(state, instanceId)
if not instance then
return failure({
makeError("instance_not_found", "$.instanceId", "카드 인스턴스를 찾을 수 없습니다."),
})
end
if instance.owner ~= "player" or instance.zone ~= "hand" then
return failure({
makeError(
"preview_card_not_in_hand",
"$.instanceId",
"선택 단계 효과의 원본 카드는 플레이어 손패에 있어야 합니다."
),
})
end
local card = staticData.cards[instance.cardId]
if type(card) ~= "table" or card.id ~= instance.cardId then
return failure({
makeError("unknown_card", "$.instanceId", "정적 DB에서 카드를 찾을 수 없습니다."),
})
end
if card.selectionPreview == nil then
return success({}, card.id)
end
local policy = card.selectionPreview
if type(policy) ~= "table" or getmetatable(policy) ~= nil then
return failure({
makeError(
"invalid_preview_policy",
"$.card.selectionPreview",
"선택 단계 효과 정책은 일반 테이블이어야 합니다."
),
})
end
checkAllowedKeys(policy, { effects = true }, "$.card.selectionPreview", errors)
if card.owner ~= "player" or card.cardType ~= "chain" then
table.insert(errors, makeError(
"preview_requires_player_chain",
"$.card.selectionPreview",
"선택 단계 효과는 플레이어 연계 카드에만 선언할 수 있습니다."
))
end
if not isDenseArray(policy.effects) or #policy.effects == 0 then
table.insert(errors, makeError(
"invalid_preview_effects",
"$.card.selectionPreview.effects",
"선택 단계 효과는 비어 있지 않은 연속 배열이어야 합니다."
))
end
if #errors > 0 then
return failure(errors)
end
local commands = {}
local seenIds = {}
for index, effect in ipairs(policy.effects) do
local path = "$.card.selectionPreview.effects[" .. index .. "]"
if type(effect) ~= "table" or getmetatable(effect) ~= nil then
table.insert(errors, makeError(
"invalid_preview_effect",
path,
"선택 단계 효과는 일반 테이블이어야 합니다."
))
else
checkAllowedKeys(effect, {
id = true,
op = true,
target = true,
amount = true,
}, path, errors)
if type(effect.id) ~= "string"
or string.match(effect.id, "^[a-z][a-z0-9_]*$") == nil then
table.insert(errors, makeError(
"invalid_preview_effect_id",
path .. ".id",
"선택 단계 효과 ID는 lower_snake_case여야 합니다."
))
elseif seenIds[effect.id] then
table.insert(errors, makeError(
"duplicate_preview_effect_id",
path .. ".id",
"선택 단계 효과 ID가 중복되었습니다."
))
else
seenIds[effect.id] = true
end
local registered = type(staticData.registry.effectOps) == "table"
and staticData.registry.effectOps[effect.op]
or nil
if type(effect.op) ~= "string" or not registered then
table.insert(errors, makeError(
"unknown_preview_op",
path .. ".op",
"등록되지 않은 선택 단계 효과 작업입니다."
))
elseif effect.op ~= "draw_cards" then
table.insert(errors, makeError(
"unsupported_preview_op",
path .. ".op",
"선택 단계 효과 v1은 draw_cards만 지원합니다."
))
end
if effect.target ~= "player" then
table.insert(errors, makeError(
"invalid_preview_target",
path .. ".target",
"선택 단계 드로우 대상은 player여야 합니다."
))
end
if not isInteger(effect.amount, 1) then
table.insert(errors, makeError(
"invalid_preview_amount",
path .. ".amount",
"선택 단계 드로우 수량은 1 이상의 정수여야 합니다."
))
end
commands[index] = {
id = effect.id,
op = effect.op,
target = effect.target,
amount = effect.amount,
}
end
end
if #errors > 0 then
return failure(errors)
end
return success(commands, card.id)
end
local SUPPORTED_COMMANDS = {
damage_resistance = true,
recover_resistance = true,
lose_stealth = true,
recover_stealth = true,
draw_cards = true,
skip_actions = true,
add_mood_token = true,
remove_mood_token = true,
force_mood = true,
}
local RESOURCE_COMMANDS = {
damage_resistance = {
owner = "character",
field = "resistance",
direction = -1,
},
recover_resistance = {
owner = "character",
field = "resistance",
direction = 1,
},
lose_stealth = {
owner = "player",
field = "stealth",
direction = -1,
},
recover_stealth = {
owner = "player",
field = "stealth",
direction = 1,
},
}
local function addError(errors, code, path, message)
table.insert(errors, makeError(code, path, message))
end
local function reportSuccess(fields)
local report = {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
}
for key, value in pairs(fields or {}) do
report[key] = value
end
return report
end
local function cloneData(value, path, active)
path = path or "$"
local valueType = type(value)
if value == nil or valueType == "string" or valueType == "boolean" then
return value, nil
end
if valueType == "number" then
if not isFinite(value) then
return nil, makeError("non_finite_number", path, "효과 입력에는 유한한 숫자만 허용됩니다.")
end
return value, nil
end
if valueType ~= "table" then
return nil, makeError(
"non_data_value",
path,
"효과 입력에는 함수, 스레드, userdata를 넣을 수 없습니다."
)
end
if getmetatable(value) ~= nil then
return nil, makeError("metatable_not_allowed", path, "효과 입력에는 메타테이블을 사용할 수 없습니다.")
end
active = active or {}
if active[value] then
return nil, makeError("cyclic_value", path, "효과 입력에는 순환 참조를 사용할 수 없습니다.")
end
active[value] = true
local copy = {}
for key, item in pairs(value) do
local keyType = type(key)
if keyType ~= "string" and not isInteger(key, 1) then
active[value] = nil
return nil, makeError(
"invalid_table_key",
path,
"효과 입력 테이블 키는 문자열 또는 1 이상의 정수여야 합니다."
)
end
local childPath = keyType == "string"
and (path .. "." .. key)
or (path .. "[" .. key .. "]")
local cloned, cloneError = cloneData(item, childPath, active)
if cloneError then
active[value] = nil
return nil, cloneError
end
copy[key] = cloned
end
active[value] = nil
return copy, nil
end
local function projectMood(staticInput, spec)
local staticData = normalizeStaticData(staticInput)
local moods = type(staticData) == "table"
and type(staticData.registry) == "table"
and staticData.registry.moods
or nil
if type(moods) ~= "table" or type(spec) ~= "table" then
return failure({ makeError("invalid_mood_projection", "$.spec", "무드 투영 입력이 올바르지 않습니다.") })
end
if not isInteger(spec.turnNumber, 1) or type(moods[spec.mood]) ~= "table" then
return failure({ makeError("invalid_mood_projection", "$.spec", "무드 투영 턴 또는 현재 무드가 올바르지 않습니다.") })
end
local order = {}
local moodCount = 0
for moodId, mood in pairs(moods) do
if type(mood) ~= "table" or mood.id ~= moodId or not isInteger(mood.order, 1) or order[mood.order] ~= nil then
return failure({ makeError("invalid_mood_registry", "$.staticData.registry.moods", "무드 순서가 올바르지 않습니다.") })
end
order[mood.order] = moodId
moodCount = moodCount + 1
end
for index = 1, moodCount do
if order[index] == nil then
return failure({ makeError("invalid_mood_registry", "$.staticData.registry.moods", "무드 순서가 1부터 이어지지 않습니다.") })
end
end
local tokens = {}
local sourceTokens = spec.moodTokens or {}
if type(sourceTokens) ~= "table" or getmetatable(sourceTokens) ~= nil then
return failure({ makeError("invalid_mood_tokens", "$.spec.moodTokens", "무드 토큰이 객체가 아닙니다.") })
end
for moodId in pairs(sourceTokens) do
if moods[moodId] == nil then
return failure({ makeError("unknown_mood", "$.spec.moodTokens." .. tostring(moodId), "등록되지 않은 무드의 토큰입니다.") })
end
end
for _, moodId in ipairs(order) do
local count = sourceTokens[moodId] or 0
if not isInteger(count, 0) then
return failure({ makeError("invalid_mood_token_count", "$.spec.moodTokens." .. moodId, "무드 토큰 수는 0 이상의 정수여야 합니다.") })
end
tokens[moodId] = count
end
local forcedRequests, cloneError = cloneData(spec.forcedMoodRequests or {}, "$.spec.forcedMoodRequests")
if cloneError then return failure({ cloneError }) end
if not isDenseArray(forcedRequests) then
return failure({ makeError("invalid_forced_mood_requests", "$.spec.forcedMoodRequests", "강제 무드 요청이 배열이 아닙니다.") })
end
for index, request in ipairs(forcedRequests) do
if type(request) ~= "table" or type(moods[request.mood]) ~= "table" then
return failure({ makeError("unknown_mood", "$.spec.forcedMoodRequests[" .. index .. "].mood", "등록되지 않은 강제 변경 무드입니다.") })
end
end
local commands = spec.commands or {}
if not isDenseArray(commands) then
return failure({ makeError("invalid_mood_commands", "$.spec.commands", "무드 투영 명령이 배열이 아닙니다.") })
end
for index, command in ipairs(commands) do
local path = "$.spec.commands[" .. index .. "]"
if type(command) ~= "table" or type(moods[command.mood]) ~= "table" then
return failure({ makeError("invalid_mood_command", path, "무드 투영 명령이 올바르지 않습니다.") })
elseif command.op == "add_mood_token" or command.op == "remove_mood_token" then
if not isInteger(command.amount, 1) then
return failure({ makeError("invalid_mood_command", path .. ".amount", "무드 토큰 명령 수치가 올바르지 않습니다.") })
end
local before = tokens[command.mood]
local after = command.op == "add_mood_token"
and before + command.amount
or math.max(0, before - command.amount)
if not isInteger(after, 0) or after > 9007199254740991 then
return failure({ makeError("mood_token_overflow", path, "무드 토큰 수가 안전한 범위를 벗어났습니다.") })
end
tokens[command.mood] = after
elseif command.op == "force_mood" then
forcedRequests[#forcedRequests + 1] = { mood = command.mood }
else
return failure({ makeError("unsupported_mood_command", path .. ".op", "무드 투영이 지원하지 않는 명령입니다.") })
end
end
local tokensBefore, tokensBeforeError = cloneData(tokens, "$.projection.tokensBefore")
if tokensBeforeError then return failure({ tokensBeforeError }) end
local payload = {
before = spec.mood,
after = spec.mood,
applied = false,
forcedCount = #forcedRequests,
forceCancelled = #forcedRequests >= 2,
tokensBefore = tokensBefore,
}
if #forcedRequests == 1 then
payload.after = forcedRequests[1].mood
payload.applied = payload.before ~= payload.after
payload.resolution = "forced"
payload.targetMood = payload.after
else
local maximum = 0
local leaders = {}
for _, moodId in ipairs(order) do
local count = tokens[moodId]
if count > maximum then
maximum = count
leaders = { moodId }
elseif count == maximum then
leaders[#leaders + 1] = moodId
end
end
if maximum < 3 then
payload.resolution = "none"
elseif #leaders == 1 then
payload.after = leaders[1]
payload.applied = payload.before ~= payload.after
payload.resolution = "token"
payload.targetMood = payload.after
tokens[payload.after] = 0
else
for _, moodId in ipairs(leaders) do tokens[moodId] = tokens[moodId] - 1 end
payload.resolution = "tie"
payload.tiedMoods = leaders
end
end
local tokensAfter, tokensAfterError = cloneData(tokens, "$.projection.tokensAfter")
if tokensAfterError then return failure({ tokensAfterError }) end
payload.tokensAfter = tokensAfter
local function stealthDelta(moodId, repeated)
if moodId == "rejection" then return repeated and -4 or -2 end
if moodId == "suspicion" then return repeated and -2 or -1 end
if moodId == "confusion" then return 1 end
if moodId == "compliance" then return 2 end
return 0
end
local repeated = spec.turnNumber > 1 and payload.before == payload.after
local finalStealthDelta = stealthDelta(payload.after, repeated)
local tokenProgress = 0
for _, moodId in ipairs(order) do
tokenProgress = tokenProgress + stealthDelta(moodId, false) * tokensAfter[moodId]
end
return reportSuccess({
resolution = {
mood = payload.after,
moodTokens = tokensAfter,
payload = payload,
stealthDelta = finalStealthDelta,
tokenProgress = tokenProgress,
},
})
end
local function appendNestedErrors(errors, prefix, nested)
for _, item in ipairs(type(nested) == "table" and nested or {}) do
local nestedPath = type(item.path) == "string" and item.path or "$"
if string.sub(nestedPath, 1, 1) == "$" then
nestedPath = prefix .. string.sub(nestedPath, 2)
else
nestedPath = prefix .. "." .. nestedPath
end
addError(
errors,
type(item.code) == "string" and item.code or "nested_error",
nestedPath,
type(item.message) == "string" and item.message or "하위 효과 처리에 실패했습니다."
)
end
end
local function validateRuntimeStaticData(staticData, requireCards)
staticData = normalizeStaticData(staticData)
if type(staticData) ~= "table"
or type(staticData.registry) ~= "table"
or type(staticData.registry.effectOps) ~= "table"
or type(staticData.registry.moods) ~= "table"
or (requireCards and type(staticData.cards) ~= "table") then
return nil, {
makeError(
"invalid_static_data",
"$.staticData",
"효과 평가에는 카드와 레지스트리를 포함한 전체 정적 데이터가 필요합니다."
),
}
end
return staticData, nil
end
local function validateModifiers(modifiers, path)
path = path or "$.modifiers"
if modifiers == nil then
return reportSuccess({ modifiers = {} })
end
if not isDenseArray(modifiers) then
return failure({
makeError("invalid_modifiers", path, "수치 보정은 연속 배열이어야 합니다."),
})
end
if #modifiers > 0 then
return failure({
makeError(
"unsupported_modifiers",
path,
"일반 수치 보정 파이프라인이 확정되기 전에는 비어 있지 않은 보정을 적용할 수 없습니다."
),
})
end
return reportSuccess({ modifiers = {} })
end
local function validateOptions(options)
if options == nil then
return {}, nil
end
if type(options) ~= "table" or getmetatable(options) ~= nil then
return nil, {
makeError("invalid_options", "$.options", "효과 옵션은 일반 테이블이어야 합니다."),
}
end
for key in pairs(options) do
if key ~= "modifiers" then
return nil, {
makeError(
"unexpected_option",
"$.options." .. tostring(key),
"지원하지 않는 효과 옵션입니다."
),
}
end
end
local modifierReport = validateModifiers(options.modifiers, "$.options.modifiers")
if not modifierReport.ok then
return nil, modifierReport.errors
end
return options, nil
end
local function validateCause(value, path, errors)
if type(value) ~= "string" or value == "" then
addError(errors, "invalid_command_cause", path, "효과 명령에는 비어 있지 않은 cause가 필요합니다.")
end
end
local function checkCommandKeys(command, allowed, path, errors)
for key in pairs(command) do
if type(key) ~= "string" or not allowed[key] then
addError(
errors,
"unexpected_command_field",
path .. "." .. tostring(key),
"이 효과 명령에 허용되지 않은 필드가 있습니다."
)
end
end
end
local function validateCommands(staticData, commands)
local normalizedStaticData, staticErrors = validateRuntimeStaticData(staticData, false)
if staticErrors then
return failure(staticErrors)
end
if not isDenseArray(commands) then
return failure({
makeError("invalid_commands", "$.commands", "효과 명령은 1부터 이어지는 배열이어야 합니다."),
})
end
local errors = {}
local normalized = {}
for index, command in ipairs(commands) do
local path = "$.commands[" .. index .. "]"
if type(command) ~= "table" or getmetatable(command) ~= nil then
addError(errors, "invalid_command", path, "효과 명령은 일반 테이블이어야 합니다.")
else
local registered = type(command.op) == "string"
and normalizedStaticData.registry.effectOps[command.op]
or nil
if not registered then
addError(errors, "unknown_effect_op", path .. ".op", "등록되지 않은 효과 작업입니다.")
elseif not SUPPORTED_COMMANDS[command.op] then
addError(errors, "unsupported_effect_op", path .. ".op", "v1 해결기가 지원하지 않는 효과 작업입니다.")
end
local common = { op = true, target = true, cause = true }
local output = {
op = command.op,
target = command.target,
cause = command.cause,
}
validateCause(command.cause, path .. ".cause", errors)
local resource = RESOURCE_COMMANDS[command.op]
if resource then
common.amount = true
checkCommandKeys(command, common, path, errors)
if command.target ~= resource.owner then
addError(errors, "invalid_command_target", path .. ".target", "자원 효과 대상이 올바르지 않습니다.")
end
if not isFinite(command.amount) or command.amount < 0 then
addError(errors, "invalid_command_amount", path .. ".amount", "자원 효과 수치는 0 이상의 유한한 숫자여야 합니다.")
end
output.amount = command.amount
elseif command.op == "draw_cards" then
common.amount = true
checkCommandKeys(command, common, path, errors)
if command.target ~= "player" and command.target ~= "character" then
addError(errors, "invalid_command_target", path .. ".target", "드로우 대상이 올바르지 않습니다.")
end
if not isInteger(command.amount, 1) then
addError(errors, "invalid_command_amount", path .. ".amount", "드로우 수량은 1 이상의 정수여야 합니다.")
end
output.amount = command.amount
elseif command.op == "skip_actions" then
common.scope = true
checkCommandKeys(command, common, path, errors)
if command.target ~= "player" and command.target ~= "character" then
addError(errors, "invalid_command_target", path .. ".target", "행동 생략 대상이 올바르지 않습니다.")
end
if command.scope ~= "remainingTurn" then
addError(errors, "invalid_command_scope", path .. ".scope", "v1 행동 생략 범위는 remainingTurn이어야 합니다.")
end
output.scope = command.scope
elseif command.op == "add_mood_token" or command.op == "remove_mood_token" then
common.amount = true
common.mood = true
checkCommandKeys(command, common, path, errors)
if command.target ~= "character" then
addError(errors, "invalid_command_target", path .. ".target", "무드 토큰 대상은 character여야 합니다.")
end
if not isInteger(command.amount, 1) then
addError(errors, "invalid_command_amount", path .. ".amount", "무드 토큰 수는 1 이상의 정수여야 합니다.")
end
if type(command.mood) ~= "string" or not normalizedStaticData.registry.moods[command.mood] then
addError(errors, "unknown_mood", path .. ".mood", "등록되지 않은 무드입니다.")
end
output.amount = command.amount
output.mood = command.mood
elseif command.op == "force_mood" then
common.mood = true
checkCommandKeys(command, common, path, errors)
if command.target ~= "character" then
addError(errors, "invalid_command_target", path .. ".target", "무드 강제 변경 대상은 character여야 합니다.")
end
if type(command.mood) ~= "string" or not normalizedStaticData.registry.moods[command.mood] then
addError(errors, "unknown_mood", path .. ".mood", "등록되지 않은 무드입니다.")
end
output.mood = command.mood
elseif registered and not SUPPORTED_COMMANDS[command.op] then
checkCommandKeys(command, common, path, errors)
elseif not registered then
checkCommandKeys(command, common, path, errors)
end
normalized[index] = output
end
end
if #errors > 0 then
return failure(errors)
end
return reportSuccess({ commands = normalized })
end
local function prepareCallbackValue(value, path)
local copy, cloneError = cloneData(value, path)
if cloneError then
return nil, failure({ cloneError })
end
return copy, nil
end
local function evaluateCommandCallback(staticData, callback, context, event, errorCode, errorPath)
local contextCopy, contextFailure = prepareCallbackValue(context, "$.context")
if contextFailure then
return contextFailure
end
local eventCopy = nil
if event ~= nil then
eventCopy, contextFailure = prepareCallbackValue(event, "$.event")
if contextFailure then
return contextFailure
end
end
local ok, returned = pcall(callback, contextCopy, eventCopy)
if not ok then
return failure({
makeError(errorCode, errorPath, "효과 콜백 실행에 실패했습니다: " .. tostring(returned)),
})
end
local commandReport = validateCommands(staticData, returned)
if not commandReport.ok then
return commandReport
end
return reportSuccess({ commands = commandReport.commands })
end
local function findRuntimeCard(staticData, cardId)
local normalizedStaticData, staticErrors = validateRuntimeStaticData(staticData, true)
if staticErrors then
return nil, nil, staticErrors
end
if type(cardId) ~= "string" or cardId == "" then
return nil, nil, {
makeError("invalid_card_id", "$.cardId", "카드 ID가 없습니다."),
}
end
local card = normalizedStaticData.cards[cardId]
if type(card) ~= "table" or card.id ~= cardId then
return nil, nil, {
makeError("unknown_card", "$.cardId", "정적 DB에서 카드를 찾을 수 없습니다."),
}
end
return normalizedStaticData, card, nil
end
local function evaluateCanPlay(staticData, cardId, context, options)
local _, optionsErrors = validateOptions(options)
if optionsErrors then
return failure(optionsErrors)
end
local _, card, cardErrors = findRuntimeCard(staticData, cardId)
if cardErrors then
return failure(cardErrors)
end
if card.canPlay == nil then
return reportSuccess({ cardId = cardId, playable = true })
end
if type(card.canPlay) ~= "function" then
return failure({
makeError("invalid_can_play", "$.card.canPlay", "카드 canPlay가 함수가 아닙니다."),
})
end
local contextCopy, contextFailure = prepareCallbackValue(context, "$.context")
if contextFailure then
return contextFailure
end
local ok, playable, reasonCode, unexpected = pcall(card.canPlay, contextCopy)
if not ok then
return failure({
makeError("can_play_error", "$.card.canPlay", "canPlay 실행에 실패했습니다: " .. tostring(playable)),
})
end
if type(playable) ~= "boolean" then
return failure({
makeError("invalid_can_play_result", "$.card.canPlay", "canPlay의 첫 반환값은 불리언이어야 합니다."),
})
end
if unexpected ~= nil then
return failure({
makeError("invalid_can_play_result", "$.card.canPlay", "canPlay는 두 값을 초과해 반환할 수 없습니다."),
})
end
if playable and reasonCode ~= nil then
return failure({
makeError("invalid_can_play_reason", "$.card.canPlay", "사용 가능한 카드에는 실패 사유가 없어야 합니다."),
})
end
if not playable and (type(reasonCode) ~= "string" or string.match(reasonCode, "^[a-z][a-z0-9_]*$") == nil) then
return failure({
makeError("invalid_can_play_reason", "$.card.canPlay", "사용 불가 사유는 lower_snake_case 코드여야 합니다."),
})
end
return reportSuccess({
cardId = cardId,
playable = playable,
reasonCode = reasonCode,
})
end
local function evaluateEffectChoice(staticData, cardId, choiceId, context, options)
local _, optionsErrors = validateOptions(options)
if optionsErrors then return failure(optionsErrors) end
local _, card, cardErrors = findRuntimeCard(staticData, cardId)
if cardErrors then return failure(cardErrors) end
if type(choiceId) ~= "string" or string.match(choiceId, "^[a-z][a-z0-9_]*$") == nil then
return failure({ makeError("invalid_effect_choice_id", "$.choiceId", "효과 선택지 ID가 올바르지 않습니다.") })
end
local selected
for _, choice in ipairs(type(card.effectChoices) == "table" and card.effectChoices or {}) do
if type(choice) == "table" and choice.id == choiceId then
selected = choice
break
end
end
if selected == nil then
return failure({ makeError("unknown_effect_choice", "$.choiceId", "카드에 등록되지 않은 효과 선택지입니다.") })
end
if selected.canSelect == nil then
return reportSuccess({ cardId = cardId, choiceId = choiceId, selectable = true })
end
if type(selected.canSelect) ~= "function" then
return failure({ makeError("invalid_effect_choice_predicate", "$.card.effectChoices", "효과 선택 가능 조건이 함수가 아닙니다.") })
end
local contextCopy, contextFailure = prepareCallbackValue(context, "$.context")
if contextFailure then return contextFailure end
local ok, selectable, reasonCode, unexpected = pcall(selected.canSelect, contextCopy)
if not ok then
return failure({ makeError("effect_choice_predicate_error", "$.card.effectChoices", "효과 선택 가능 조건 실행에 실패했습니다: " .. tostring(selectable)) })
end
if type(selectable) ~= "boolean" or unexpected ~= nil then
return failure({ makeError("invalid_effect_choice_result", "$.card.effectChoices", "효과 선택 가능 조건은 불리언과 선택적 사유 코드만 반환해야 합니다.") })
end
if selectable and reasonCode ~= nil then
return failure({ makeError("invalid_effect_choice_reason", "$.card.effectChoices", "선택 가능한 효과에는 실패 사유가 없어야 합니다.") })
end
if not selectable and (type(reasonCode) ~= "string" or string.match(reasonCode, "^[a-z][a-z0-9_]*$") == nil) then
return failure({ makeError("invalid_effect_choice_reason", "$.card.effectChoices", "선택 불가 사유는 lower_snake_case 코드여야 합니다.") })
end
return reportSuccess({
cardId = cardId,
choiceId = choiceId,
selectable = selectable,
reasonCode = reasonCode,
})
end
local function evaluateCardResolve(staticData, cardId, context, options)
local _, optionsErrors = validateOptions(options)
if optionsErrors then
return failure(optionsErrors)
end
local _, card, cardErrors = findRuntimeCard(staticData, cardId)
if cardErrors then
return failure(cardErrors)
end
if card.resolve == nil then
return reportSuccess({ cardId = cardId, commands = {} })
end
if type(card.resolve) ~= "function" then
return failure({
makeError("invalid_resolve", "$.card.resolve", "카드 resolve가 함수가 아닙니다."),
})
end
local evaluated = evaluateCommandCallback(
staticData,
card.resolve,
context,
nil,
"card_resolve_error",
"$.card.resolve"
)
if not evaluated.ok then
return evaluated
end
evaluated.cardId = cardId
return evaluated
end
local function evaluateMoodEffect(staticData, cardId, moodId, context, options)
local _, optionsErrors = validateOptions(options)
if optionsErrors then
return failure(optionsErrors)
end
local normalizedStaticData, card, cardErrors = findRuntimeCard(staticData, cardId)
if cardErrors then
return failure(cardErrors)
end
if type(moodId) ~= "string" or not normalizedStaticData.registry.moods[moodId] then
return failure({
makeError("unknown_mood", "$.moodId", "등록되지 않은 무드입니다."),
})
end
local callback = type(card.moodEffects) == "table" and card.moodEffects[moodId] or nil
if callback == nil then
return reportSuccess({ cardId = cardId, moodId = moodId, commands = {} })
end
if type(callback) ~= "function" then
return failure({
makeError("invalid_mood_callback", "$.card.moodEffects." .. moodId, "무드 효과가 함수가 아닙니다."),
})
end
local evaluated = evaluateCommandCallback(
staticData,
callback,
context,
nil,
"mood_effect_error",
"$.card.moodEffects." .. moodId
)
if not evaluated.ok then
return evaluated
end
evaluated.cardId = cardId
evaluated.moodId = moodId
return evaluated
end
local function validateTriggerInput(staticData, triggerSpec, event, options)
local _, optionsErrors = validateOptions(options)
if optionsErrors then
return optionsErrors
end
local normalizedStaticData, staticErrors = validateRuntimeStaticData(staticData, false)
if staticErrors then
return staticErrors
end
if type(triggerSpec) ~= "table" or getmetatable(triggerSpec) ~= nil then
return {
makeError("invalid_trigger", "$.trigger", "트리거 설정은 일반 테이블이어야 합니다."),
}
end
if type(event) ~= "table" or getmetatable(event) ~= nil then
return {
makeError("invalid_trigger_event", "$.event", "트리거 입력 사건은 일반 테이블이어야 합니다."),
}
end
if type(event.type) ~= "string"
or type(normalizedStaticData.registry.events) ~= "table"
or not normalizedStaticData.registry.events[event.type] then
return {
makeError("unknown_trigger_event", "$.event.type", "등록되지 않은 트리거 입력 사건입니다."),
}
end
if triggerSpec.event ~= nil
and (type(triggerSpec.event) ~= "string"
or not normalizedStaticData.registry.events[triggerSpec.event]) then
return {
makeError("unknown_trigger_event", "$.trigger.event", "트리거가 등록되지 않은 사건을 참조합니다."),
}
end
if triggerSpec.side ~= nil
and triggerSpec.side ~= "player"
and triggerSpec.side ~= "character" then
return {
makeError("invalid_trigger_side", "$.trigger.side", "트리거 진영은 player 또는 character여야 합니다."),
}
end
return nil
end
local function evaluateTriggerCondition(staticData, triggerSpec, context, event, options)
local inputErrors = validateTriggerInput(staticData, triggerSpec, event, options)
if inputErrors then
return failure(inputErrors)
end
if triggerSpec.event ~= nil and triggerSpec.event ~= event.type then
return reportSuccess({ matched = false })
end
if triggerSpec.side ~= nil and triggerSpec.side ~= event.side then
return reportSuccess({ matched = false })
end
local matched = true
if triggerSpec.trigger ~= nil then
if type(triggerSpec.trigger) ~= "function" then
return failure({
makeError("invalid_trigger_condition", "$.trigger.trigger", "트리거 조건이 함수가 아닙니다."),
})
end
local contextCopy, contextFailure = prepareCallbackValue(context, "$.context")
if contextFailure then
return contextFailure
end
local eventCopy
eventCopy, contextFailure = prepareCallbackValue(event, "$.event")
if contextFailure then
return contextFailure
end
local ok, result = pcall(triggerSpec.trigger, contextCopy, eventCopy)
if not ok then
return failure({
makeError(
"trigger_condition_error",
"$.trigger.trigger",
"트리거 조건 실행에 실패했습니다: " .. tostring(result)
),
})
end
if type(result) ~= "boolean" then
return failure({
makeError("invalid_trigger_result", "$.trigger.trigger", "트리거 조건은 불리언을 반환해야 합니다."),
})
end
matched = result
end
return reportSuccess({ matched = matched })
end
local function evaluateTriggerResolve(staticData, triggerSpec, context, event, options)
local inputErrors = validateTriggerInput(staticData, triggerSpec, event, options)
if inputErrors then
return failure(inputErrors)
end
if type(triggerSpec.resolve) ~= "function" then
return failure({
makeError("invalid_trigger_resolve", "$.trigger.resolve", "트리거 resolve가 함수가 아닙니다."),
})
end
local evaluated = evaluateCommandCallback(
staticData,
triggerSpec.resolve,
context,
event,
"trigger_resolve_error",
"$.trigger.resolve"
)
if not evaluated.ok then
return evaluated
end
return evaluated
end
local function evaluateTrigger(staticData, triggerSpec, context, event, options)
local condition = evaluateTriggerCondition(staticData, triggerSpec, context, event, options)
if not condition.ok then
return condition
end
if not condition.matched then
return reportSuccess({ matched = false, commands = {} })
end
local evaluated = evaluateTriggerResolve(staticData, triggerSpec, context, event, options)
if not evaluated.ok then
return evaluated
end
evaluated.matched = true
return evaluated
end
local function callCardZones(action, ...)
if type(runScript) ~= "function" then
return nil, {
makeError("runtime_unavailable", "$.runtime.cardZones", "cardZones 실행기를 찾을 수 없습니다."),
}
end
local ok, report = pcall(runScript, triggerId, "cardZones", action, ...)
if not ok then
return nil, {
makeError("card_zones_error", "$.runtime.cardZones", "cardZones 실행에 실패했습니다: " .. tostring(report)),
}
end
if type(report) ~= "table" or report.ok ~= true or type(report.state) ~= "table" then
local errors = {}
appendNestedErrors(errors, "$.runtime.cardZones", type(report) == "table" and report.errors or {})
if #errors == 0 then
addError(errors, "card_zones_error", "$.runtime.cardZones", "cardZones가 올바른 성공 결과를 반환하지 않았습니다.")
end
return nil, errors
end
return report, nil
end
local function drawReceipt(state, owner)
local receipt = {
deckInstanceIds = {},
handInstanceIds = {},
discardInstanceIds = {},
rng = {
seed = type(state.rng) == "table" and state.rng.seed or nil,
cursor = type(state.rng) == "table" and state.rng.cursor or nil,
},
}
local grouped = {
deck = {},
hand = {},
discard = {},
}
for _, instance in ipairs(type(state.cardInstances) == "table" and state.cardInstances or {}) do
if type(instance) == "table"
and instance.owner == owner
and grouped[instance.zone] ~= nil then
grouped[instance.zone][#grouped[instance.zone] + 1] = instance
end
end
for _, zone in ipairs({ "deck", "hand", "discard" }) do
table.sort(grouped[zone], function(left, right)
if left.position ~= right.position then
return left.position < right.position
end
return left.instanceId < right.instanceId
end)
local output = receipt[zone .. "InstanceIds"]
for _, instance in ipairs(grouped[zone]) do
output[#output + 1] = instance.instanceId
end
end
return receipt
end
local function applyCommands(staticData, working, commands, options)
local _, optionsErrors = validateOptions(options)
if optionsErrors then
return failure(optionsErrors)
end
local normalizedStaticData, staticErrors = validateRuntimeStaticData(staticData, false)
if staticErrors then
return failure(staticErrors)
end
local commandReport = validateCommands(normalizedStaticData, commands)
if not commandReport.ok then
return commandReport
end
if type(working) ~= "table" or getmetatable(working) ~= nil then
return failure({
makeError("invalid_working", "$.working", "효과 working 값은 일반 테이블이어야 합니다."),
})
end
if type(working.state) ~= "table" then
return failure({
makeError("invalid_working_state", "$.working.state", "효과를 적용할 전투 상태가 없습니다."),
})
end
if working.transient ~= nil and type(working.transient) ~= "table" then
return failure({
makeError("invalid_transient", "$.working.transient", "효과 transient 값은 테이블이어야 합니다."),
})
end
local state, cloneError = cloneData(working.state, "$.working.state")
if cloneError then
return failure({ cloneError })
end
local transient
transient, cloneError = cloneData(working.transient or {}, "$.working.transient")
if cloneError then
return failure({ cloneError })
end
local applied = {}
for index, command in ipairs(commandReport.commands) do
local entry = {
index = index,
op = command.op,
target = command.target,
cause = command.cause,
changed = false,
}
local resource = RESOURCE_COMMANDS[command.op]
if resource then
local ownerState = state[resource.owner]
local before = type(ownerState) == "table" and ownerState[resource.field] or nil
if not isFinite(before) then
return failure({
makeError(
"invalid_resource_state",
"$.working.state." .. resource.owner .. "." .. resource.field,
"효과 대상 자원이 유한한 숫자가 아닙니다."
),
})
end
local after = before + resource.direction * command.amount
if not isFinite(after) then
return failure({
makeError("non_finite_result", "$.commands[" .. index .. "]", "효과 결과가 유한한 숫자 범위를 벗어났습니다."),
})
end
ownerState[resource.field] = after
entry.amount = command.amount
entry.before = before
entry.after = after
entry.changed = before ~= after
elseif command.op == "draw_cards" then
local before = drawReceipt(state, command.target)
local zoneReport, zoneErrors = callCardZones("draw", state, command.target, command.amount)
if zoneErrors then
return failure(zoneErrors)
end
state = zoneReport.state
entry.amount = command.amount
entry.drawnInstanceIds = zoneReport.drawnInstanceIds or {}
entry.before = before
entry.after = drawReceipt(state, command.target)
entry.changed = #entry.drawnInstanceIds > 0
elseif command.op == "skip_actions" then
if transient.skipRemaining == nil then
transient.skipRemaining = { player = false, character = false }
elseif type(transient.skipRemaining) ~= "table" then
return failure({
makeError("invalid_skip_state", "$.working.transient.skipRemaining", "남은 행동 생략 상태가 테이블이 아닙니다."),
})
end
local before = transient.skipRemaining[command.target] == true
transient.skipRemaining[command.target] = true
entry.scope = command.scope
entry.before = before
entry.after = true
entry.changed = not before
elseif command.op == "add_mood_token" or command.op == "remove_mood_token" then
if type(state.character) ~= "table" then
return failure({
makeError("invalid_mood_state", "$.working.state.character", "캐릭터 무드 상태가 없습니다."),
})
end
if state.character.moodTokens == nil then
state.character.moodTokens = {}
end
if type(state.character.moodTokens) ~= "table" then
return failure({
makeError("invalid_mood_tokens", "$.working.state.character.moodTokens", "무드 토큰 상태가 테이블이 아닙니다."),
})
end
for moodId in pairs(normalizedStaticData.registry.moods) do
local count = state.character.moodTokens[moodId]
if count == nil then
state.character.moodTokens[moodId] = 0
elseif not isInteger(count, 0) then
return failure({
makeError("invalid_mood_token_count", "$.working.state.character.moodTokens." .. moodId, "무드 토큰 수가 0 이상의 정수가 아닙니다."),
})
end
end
local before = state.character.moodTokens[command.mood] or 0
if not isInteger(before, 0) then
return failure({
makeError("invalid_mood_token_count", "$.working.state.character.moodTokens." .. command.mood, "무드 토큰 수가 0 이상의 정수가 아닙니다."),
})
end
local after = command.op == "add_mood_token"
and (before + command.amount)
or math.max(0, before - command.amount)
if after > 9007199254740991 then
return failure({
makeError("mood_token_overflow", "$.commands[" .. index .. "].amount", "무드 토큰 수가 안전한 정수 범위를 벗어났습니다."),
})
end
entry.mood = command.mood
entry.amount = command.amount
entry.before = before
entry.after = after
entry.changed = before ~= after
state.character.moodTokens[command.mood] = after
elseif command.op == "force_mood" then
if transient.forcedMoodRequests == nil then
transient.forcedMoodRequests = {}
elseif not isDenseArray(transient.forcedMoodRequests) then
return failure({
makeError("invalid_forced_mood_requests", "$.working.transient.forcedMoodRequests", "무드 강제 변경 요청이 연속 배열이 아닙니다."),
})
end
entry.mood = command.mood
entry.before = #transient.forcedMoodRequests
transient.forcedMoodRequests[#transient.forcedMoodRequests + 1] = {
mood = command.mood,
cause = command.cause,
}
entry.after = #transient.forcedMoodRequests
entry.changed = true
end
applied[index] = entry
end
return reportSuccess({
state = state,
transient = transient,
commands = commandReport.commands,
applied = applied,
})
end
local arguments = { ... }
local actions = {
evaluateSelectionPreview = evaluateSelectionPreview,
evaluateCanPlay = evaluateCanPlay,
evaluateEffectChoice = evaluateEffectChoice,
evaluateCardResolve = evaluateCardResolve,
evaluateMoodEffect = evaluateMoodEffect,
evaluateTriggerCondition = evaluateTriggerCondition,
evaluateTriggerResolve = evaluateTriggerResolve,
evaluateTrigger = evaluateTrigger,
projectMood = projectMood,
validateCommands = validateCommands,
validateModifiers = validateModifiers,
applyCommands = applyCommands,
}
local handler = actions[action]
if not handler then
return failure({
makeError(
"unknown_action",
"$.action",
"지원하지 않는 효과 엔진 작업입니다: " .. tostring(action)
),
})
end
return handler(arguments[1], arguments[2], arguments[3], arguments[4], arguments[5])
end)
#37gameSetup.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local KIND = "gameSetupV1"
local TOTAL_DRAFTS = 10
local OFFER_SIZE = 3
local MAX_COPIES = 2
local MINIMUM_POOL_SIZE = 7
local COMMON_WEIGHT = 9
local RARE_WEIGHT = 3
local CHARACTER_OFFER_SIZE = 3
local MINIMUM_CHARACTER_POOL_SIZE = 3
local BATTLE_SEED_MODULUS = 2147483646
local BATTLE_SEED_DOMAIN_OFFSET = 104729
local MAX_SAFE_INTEGER = 9007199254740991
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
errors = errors,
}
end
local function success(state, applied, stale)
local report = {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
state = state,
}
if applied ~= nil then
report.applied = applied
end
if stale ~= nil then
report.stale = stale
end
return report
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isSafeInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and math.abs(value) <= MAX_SAFE_INTEGER
and (minimum == nil or value >= minimum)
end
local function isAsciiId(value)
return type(value) == "string"
and string.match(value, "^[a-z][a-z0-9_]*$") ~= nil
end
local function isRuntimeId(value)
return type(value) == "string"
and string.match(value, "^[A-Za-z0-9][A-Za-z0-9_-]*$") ~= nil
end
local function isInteractionToken(value)
return type(value) == "string"
and string.match(value, "^game%-setup%-draft%-v1:%d+:%d+:%d+$") ~= nil
end
local function isCharacterInteractionToken(value)
return type(value) == "string"
and string.match(value, "^game%-setup%-character%-v1:%d+:%d+:%d+$") ~= nil
end
local function appendError(errors, code, path, message)
errors[#errors + 1] = makeError(code, path, message)
end
local function appendNestedErrors(errors, nested, fallbackPath)
if type(nested) ~= "table" then
appendError(errors, "invalid_nested_errors", fallbackPath, "하위 모듈의 오류 목록이 올바르지 않습니다.")
return
end
for _, item in ipairs(nested) do
errors[#errors + 1] = {
code = tostring(type(item) == "table" and item.code or "nested_error"),
path = tostring(type(item) == "table" and item.path or fallbackPath),
message = tostring(type(item) == "table" and item.message or "하위 모듈 작업이 실패했습니다."),
}
end
end
local function pathForKey(path, key)
if type(key) == "string" and string.match(key, "^[A-Za-z_][A-Za-z0-9_]*$") then
return path .. "." .. key
end
if type(key) == "string" or type(key) == "number" or type(key) == "boolean" then
return path .. "[" .. string.format("%q", tostring(key)) .. "]"
end
return path .. "[<" .. type(key) .. ">]"
end
local function inspectJsonTable(value, path, errors)
if type(value) ~= "table" then
appendError(errors, "expected_table", path, "테이블이어야 합니다.")
return nil, nil
end
if getmetatable(value) ~= nil then
appendError(errors, "metatable_not_allowed", path, "JSON 값에는 메타테이블을 사용할 수 없습니다.")
return nil, nil
end
local numericCount = 0
local maximum = 0
local hasNumeric = false
local hasString = false
local stringKeys = {}
for key in pairs(value) do
if type(key) == "number" then
hasNumeric = true
numericCount = numericCount + 1
if not isSafeInteger(key, 1) then
appendError(errors, "invalid_array_index", path, "배열 인덱스는 1 이상의 연속된 정수여야 합니다.")
return nil, nil
end
if key > maximum then
maximum = key
end
elseif type(key) == "string" then
hasString = true
stringKeys[#stringKeys + 1] = key
else
appendError(errors, "invalid_object_key", path, "JSON 객체 키는 문자열이어야 합니다.")
return nil, nil
end
end
if hasNumeric and hasString then
appendError(errors, "mixed_table", path, "배열 인덱스와 객체 키를 함께 사용할 수 없습니다.")
return nil, nil
end
if hasNumeric then
if numericCount ~= maximum then
appendError(errors, "sparse_array", path, "배열 인덱스는 1부터 빈틈없이 이어져야 합니다.")
return nil, nil
end
return "array", maximum
end
table.sort(stringKeys)
return "object", stringKeys
end
local function validateJson(value, path, errors, active)
local valueType = type(value)
if valueType == "string" or valueType == "boolean" then
return
end
if valueType == "number" then
if not isFinite(value) then
appendError(errors, "non_finite_number", path, "NaN과 무한대는 JSON 상태에 저장할 수 없습니다.")
end
return
end
if valueType ~= "table" then
appendError(errors, "unsupported_type", path, "JSON 상태에 저장할 수 없는 자료형입니다: " .. valueType)
return
end
if getmetatable(value) ~= nil then
appendError(errors, "metatable_not_allowed", path, "JSON 값에는 메타테이블을 사용할 수 없습니다.")
return
end
active = active or {}
if active[value] then
appendError(errors, "circular_reference", path, "순환 참조가 있는 JSON 값은 사용할 수 없습니다.")
return
end
active[value] = true
local kind, shape = inspectJsonTable(value, path, errors)
if kind == "array" then
for index = 1, shape do
validateJson(value[index], path .. "[" .. index .. "]", errors, active)
end
elseif kind == "object" then
for _, key in ipairs(shape) do
validateJson(value[key], pathForKey(path, key), errors, active)
end
end
active[value] = nil
end
local function checkAllowedKeys(value, allowed, path, errors)
if type(value) ~= "table" or getmetatable(value) ~= nil then
return
end
for key in pairs(value) do
if type(key) ~= "string" or not allowed[key] then
appendError(errors, "unknown_field", pathForKey(path, key), "허용되지 않은 필드입니다.")
end
end
end
local function denseArrayLength(value, path, errors)
local kind, length = inspectJsonTable(value, path, errors)
-- Lua의 빈 테이블은 빈 객체와 빈 배열을 구분할 수 없으므로,
-- 배열을 요구하는 이 문맥에서는 빈 테이블을 길이 0으로 해석한다.
if kind == "object" and type(length) == "table" and #length == 0 then
return 0
end
if kind ~= "array" then
if kind ~= nil then
appendError(errors, "expected_array", path, "1부터 시작하는 연속 배열이어야 합니다.")
end
return nil
end
return length
end
local function cloneJson(value, path, active)
local valueType = type(value)
if valueType ~= "table" then
return value
end
active = active or {}
if active[value] then
error(makeError("circular_reference", path, "순환 참조가 있는 값을 복제할 수 없습니다."), 0)
end
active[value] = true
local copy = {}
for key, item in pairs(value) do
copy[key] = cloneJson(item, pathForKey(path, key), active)
end
active[value] = nil
return copy
end
local function cloneChecked(value, path)
local ok, copy = pcall(cloneJson, value, path or "$", {})
if not ok then
if type(copy) == "table" and copy.code then
return nil, copy
end
return nil, makeError("clone_failed", path or "$", "JSON 값 복제에 실패했습니다: " .. tostring(copy))
end
return copy, nil
end
local function deepEqual(left, right, seen)
if type(left) ~= type(right) then
return false
end
if type(left) ~= "table" then
return left == right
end
seen = seen or {}
seen[left] = seen[left] or {}
if seen[left][right] then
return true
end
seen[left][right] = true
for key, item in pairs(left) do
if not deepEqual(item, right[key], seen) then
return false
end
end
for key in pairs(right) do
if left[key] == nil then
return false
end
end
return true
end
local function normalizeStaticData(staticData)
if type(staticData) == "table"
and getmetatable(staticData) == nil
and type(rawget(staticData, "data")) == "table" then
return rawget(staticData, "data")
end
return staticData
end
local function buildPlayerPool(staticInput)
local errors = {}
local staticData = normalizeStaticData(staticInput)
if type(staticData) ~= "table" or getmetatable(staticData) ~= nil then
appendError(errors, "invalid_static_data", "$.staticData", "정적 데이터는 메타테이블이 없는 테이블이어야 합니다.")
return nil, errors
end
local cards = rawget(staticData, "cards")
if type(cards) ~= "table" or getmetatable(cards) ~= nil then
appendError(errors, "invalid_card_database", "$.staticData.cards", "cards는 메타테이블이 없는 카드 맵이어야 합니다.")
return nil, errors
end
local cardIds = {}
local weights = {}
for cardId, card in pairs(cards) do
local cardPath = pathForKey("$.staticData.cards", cardId)
if type(cardId) ~= "string" then
appendError(errors, "invalid_card_key", "$.staticData.cards", "카드 맵 키는 ASCII ID 문자열이어야 합니다.")
elseif type(card) ~= "table" or getmetatable(card) ~= nil then
appendError(errors, "invalid_card_record", cardPath, "카드 레코드는 메타테이블이 없는 테이블이어야 합니다.")
elseif rawget(card, "owner") == "player" then
if not isAsciiId(cardId) then
appendError(errors, "invalid_player_card_id", cardPath, "플레이어 카드 키는 ASCII cardId여야 합니다.")
elseif card.rarity ~= "common" and card.rarity ~= "rare" then
appendError(errors, "invalid_player_card_rarity", cardPath .. ".rarity", "플레이어 카드 희귀도는 common 또는 rare여야 합니다.")
else
cardIds[#cardIds + 1] = cardId
weights[cardId] = card.rarity == "rare" and RARE_WEIGHT or COMMON_WEIGHT
end
end
end
table.sort(cardIds)
if #cardIds < MINIMUM_POOL_SIZE then
appendError(
errors,
"insufficient_player_card_pool",
"$.staticData.cards",
"10회 드래프트에서 항상 3장을 제시하려면 플레이어 카드가 최소 7종 필요합니다."
)
end
if #errors > 0 then
return nil, errors
end
return { cardIds = cardIds, weights = weights }, nil
end
local function buildCharacterPool(staticInput)
local errors = {}
local staticData = normalizeStaticData(staticInput)
if type(staticData) ~= "table" or getmetatable(staticData) ~= nil then
appendError(errors, "invalid_static_data", "$.staticData", "정적 데이터는 메타테이블이 없는 테이블이어야 합니다.")
return nil, errors
end
local characters = rawget(staticData, "characters")
if type(characters) ~= "table" or getmetatable(characters) ~= nil then
appendError(errors, "invalid_character_database", "$.staticData.characters", "characters는 메타테이블이 없는 캐릭터 맵이어야 합니다.")
return nil, errors
end
local pool = {}
for characterId, character in pairs(characters) do
local characterPath = pathForKey("$.staticData.characters", characterId)
if type(characterId) ~= "string" or not isAsciiId(characterId) then
appendError(errors, "invalid_character_id", characterPath, "캐릭터 맵 키는 lower_snake_case ASCII ID여야 합니다.")
elseif type(character) ~= "table" or getmetatable(character) ~= nil then
appendError(errors, "invalid_character_record", characterPath, "캐릭터 레코드는 메타테이블이 없는 테이블이어야 합니다.")
else
pool[#pool + 1] = characterId
end
end
table.sort(pool)
if #pool < MINIMUM_CHARACTER_POOL_SIZE then
appendError(
errors,
"insufficient_character_pool",
"$.staticData.characters",
"캐릭터 선택에서는 서로 다른 후보가 최소 3명 필요합니다."
)
end
if #errors > 0 then
return nil, errors
end
return pool, nil
end
local function callNextIntegers(rng, ranges)
if type(runScript) ~= "function" then
return nil, nil, {
makeError("runtime_unavailable", "$.runtime.deterministicRng", "deterministicRng를 호출할 runScript가 없습니다."),
}
end
local ok, report = pcall(
runScript,
triggerId,
"deterministicRng",
"nextIntegers",
rng,
ranges
)
if not ok then
return nil, nil, {
makeError("module_call_failed", "$.runtime.deterministicRng", "deterministicRng.nextIntegers 호출에 실패했습니다: " .. tostring(report)),
}
end
if type(report) ~= "table" then
return nil, nil, {
makeError("invalid_module_result", "$.runtime.deterministicRng", "deterministicRng가 테이블 결과를 반환하지 않았습니다."),
}
end
if report.ok ~= true then
local errors = {}
appendNestedErrors(errors, report.errors, "$.runtime.deterministicRng")
return nil, nil, errors
end
local errors = {}
local valueCount = denseArrayLength(report.value, "$.runtime.deterministicRng.value", errors)
if valueCount ~= #ranges then
appendError(
errors,
"invalid_rng_batch_size",
"$.runtime.deterministicRng.value",
"deterministicRng의 batch 결과 개수가 요청 개수와 다릅니다."
)
elseif valueCount ~= nil then
for index = 1, valueCount do
local value = report.value[index]
local range = ranges[index]
if not isSafeInteger(value, range.minimum) or value > range.maximum then
appendError(
errors,
"invalid_rng_batch_value",
"$.runtime.deterministicRng.value[" .. index .. "]",
"deterministicRng의 batch 결과가 요청 범위를 벗어났습니다."
)
end
end
end
if type(report.rng) ~= "table"
or not isSafeInteger(report.rng.seed, 0)
or not isSafeInteger(report.rng.cursor, 0)
or report.rng.seed ~= rng.seed
or report.rng.cursor <= rng.cursor then
appendError(errors, "invalid_rng_result", "$.runtime.deterministicRng", "deterministicRng의 결과 계약이 올바르지 않습니다.")
end
if #errors > 0 then
return nil, nil, errors
end
local values = {}
for index = 1, valueCount do
values[index] = report.value[index]
end
return values, {
seed = report.rng.seed,
cursor = report.rng.cursor,
}, nil
end
local function buildToken(setupId, round, rng, selectedCardIds, offerCardIds)
local parts = {
"setupId=", tostring(#setupId), ":", setupId,
"|round=", tostring(round),
"|cursor=", tostring(rng.cursor),
"|selected=", tostring(#selectedCardIds), ":",
}
for _, cardId in ipairs(selectedCardIds) do
parts[#parts + 1] = tostring(#cardId)
parts[#parts + 1] = ":"
parts[#parts + 1] = cardId
parts[#parts + 1] = ";"
end
parts[#parts + 1] = "|offer="
for _, cardId in ipairs(offerCardIds) do
parts[#parts + 1] = tostring(#cardId)
parts[#parts + 1] = ":"
parts[#parts + 1] = cardId
parts[#parts + 1] = ";"
end
local canonical = table.concat(parts)
local hashA = 0
local hashB = 0
for index = 1, #canonical do
local byte = string.byte(canonical, index)
hashA = (hashA * 131 + byte) % 2147483647
hashB = (hashB * 137 + byte) % 2147483629
end
return "game-setup-draft-v1:" .. tostring(#canonical) .. ":" .. tostring(hashA) .. ":" .. tostring(hashB)
end
local function copyArray(array)
local copy = {}
for index, item in ipairs(array) do
copy[index] = item
end
return copy
end
local function buildCharacterToken(setupId, rng, characterIds)
local parts = {
"setupId=", tostring(#setupId), ":", setupId,
"|cursor=", tostring(rng.cursor),
"|characters=", tostring(#characterIds), ":",
}
for _, characterId in ipairs(characterIds) do
parts[#parts + 1] = tostring(#characterId)
parts[#parts + 1] = ":"
parts[#parts + 1] = characterId
parts[#parts + 1] = ";"
end
local canonical = table.concat(parts)
local hashA = 0
local hashB = 0
for index = 1, #canonical do
local byte = string.byte(canonical, index)
hashA = (hashA * 131 + byte) % 2147483647
hashB = (hashB * 137 + byte) % 2147483629
end
return "game-setup-character-v1:" .. tostring(#canonical) .. ":" .. tostring(hashA) .. ":" .. tostring(hashB)
end
local function generateCharacterOffer(setupId, rng, characterPool)
local candidates = copyArray(characterPool)
local ranges = {}
for pick = 1, CHARACTER_OFFER_SIZE do
ranges[pick] = {
minimum = 1,
maximum = #candidates - pick + 1,
}
end
local selectedIndices, nextRng, rngErrors = callNextIntegers(rng, ranges)
if rngErrors then
return nil, nil, rngErrors
end
local selected = {}
for pick = 1, CHARACTER_OFFER_SIZE do
local selectedIndex = selectedIndices[pick]
selected[pick] = candidates[selectedIndex]
table.remove(candidates, selectedIndex)
end
return {
characterIds = selected,
interactionToken = buildCharacterToken(setupId, nextRng, selected),
}, nextRng, nil
end
local function deriveBattleSeed(setupSeed)
-- 1-based seed 공간 안에서 고정 domain offset만큼 순환한다.
-- setup RNG cursor와 드래프트/캐릭터 선택 경로는 결과에 관여하지 않는다.
-- 큰 IEEE-754 안전 정수도 offset 덧셈 전에 범위를 줄여 중간 계산이
-- MAX_SAFE_INTEGER를 넘지 않게 한다.
local normalized = (setupSeed - 1) % BATTLE_SEED_MODULUS
return ((normalized + BATTLE_SEED_DOMAIN_OFFSET) % BATTLE_SEED_MODULUS) + 1
end
local function buildBattleSpec(setupId, setupSeed)
return {
battleId = "battle-" .. setupId,
seed = deriveBattleSeed(setupSeed),
}
end
local function generateOffer(setupId, round, rng, selectedCardIds, counts, pool)
local eligible = {}
for _, cardId in ipairs(pool.cardIds) do
if (counts[cardId] or 0) < MAX_COPIES then
eligible[#eligible + 1] = cardId
end
end
if #eligible < OFFER_SIZE then
return nil, nil, {
makeError("insufficient_eligible_cards", "$.selectedCardIds", "복제 제한을 지키며 3장을 제시할 수 없습니다."),
}
end
local offered = {}
local currentRng = rng
for pick = 1, OFFER_SIZE do
local totalWeight = 0
for _, cardId in ipairs(eligible) do totalWeight = totalWeight + pool.weights[cardId] end
local values, nextRng, rngErrors = callNextIntegers(currentRng, {
{ minimum = 1, maximum = totalWeight },
})
if rngErrors then return nil, nil, rngErrors end
local selectedIndex = 1
local cumulative = pool.weights[eligible[1]]
while values[1] > cumulative do
selectedIndex = selectedIndex + 1
cumulative = cumulative + pool.weights[eligible[selectedIndex]]
end
offered[pick] = eligible[selectedIndex]
table.remove(eligible, selectedIndex)
currentRng = nextRng
end
return {
round = round,
cardIds = offered,
interactionToken = buildToken(setupId, round, currentRng, selectedCardIds, offered),
}, currentRng, nil
end
local function contains(array, target)
for _, value in ipairs(array) do
if value == target then
return true
end
end
return false
end
local function replay(setupId, seed, selectedCardIds, pool)
-- 저장/복구 경계의 기준 구현이다. seed와 전체 선택 이력만으로
-- authority를 처음부터 다시 만들며 validate는 항상 이 경로를 쓴다.
-- 버튼 처리의 다음 상태 생성은 아래 advanceValidatedState에서 현재
-- 검증이 끝난 RNG cursor부터 이어 가되, 이 구현은 삭제하지 않는다.
local counts = {}
local rng = { seed = seed, cursor = 0 }
for round = 1, TOTAL_DRAFTS do
local history = {}
for index = 1, round - 1 do
history[index] = selectedCardIds[index]
end
local offer, nextRng, offerErrors = generateOffer(setupId, round, rng, history, counts, pool)
if offerErrors then
return nil, offerErrors
end
rng = nextRng
if round <= #selectedCardIds then
local selectedId = selectedCardIds[round]
if not contains(offer.cardIds, selectedId) then
return nil, {
makeError(
"selection_not_in_replayed_offer",
"$.selectedCardIds[" .. round .. "]",
"선택 이력이 결정적 드래프트 제안과 일치하지 않습니다."
),
}
end
counts[selectedId] = (counts[selectedId] or 0) + 1
if counts[selectedId] > MAX_COPIES then
return nil, {
makeError("card_copy_limit_exceeded", "$.selectedCardIds[" .. round .. "]", "같은 카드는 최대 2장까지 선택할 수 있습니다."),
}
end
else
return {
schemaVersion = SCHEMA_VERSION,
kind = KIND,
setupId = setupId,
phase = "deckDraft",
rng = rng,
selectedCardIds = copyArray(selectedCardIds),
offer = offer,
}, nil
end
end
return {
schemaVersion = SCHEMA_VERSION,
kind = KIND,
setupId = setupId,
phase = "deckComplete",
rng = rng,
selectedCardIds = copyArray(selectedCardIds),
}, nil
end
local function replayForPhase(setupId, seed, selectedCardIds, pool, phase, characterPool, selectedCharacterId)
local deckState, deckErrors = replay(setupId, seed, selectedCardIds, pool)
if deckErrors then
return nil, deckErrors
end
if phase == "deckDraft" or phase == "deckComplete" then
return deckState, nil
end
if deckState.phase ~= "deckComplete" then
return nil, {
makeError("character_selection_before_deck_complete", "$.state.phase", "캐릭터 선택은 초기 덱 10장을 완성한 뒤에만 시작할 수 있습니다."),
}
end
if type(characterPool) ~= "table" then
return nil, {
makeError("character_pool_unavailable", "$.staticData.characters", "캐릭터 선택을 재생할 캐릭터 풀이 없습니다."),
}
end
local characterOffer, nextRng, offerErrors = generateCharacterOffer(
setupId,
deckState.rng,
characterPool
)
if offerErrors then
return nil, offerErrors
end
if phase == "characterSelect" then
return {
schemaVersion = SCHEMA_VERSION,
kind = KIND,
setupId = setupId,
phase = "characterSelect",
rng = nextRng,
selectedCardIds = copyArray(selectedCardIds),
characterOffer = characterOffer,
}, nil
end
if phase ~= "battleReady" then
return nil, {
makeError("invalid_setup_phase", "$.state.phase", "지원하지 않는 게임 설정 phase입니다."),
}
end
if not contains(characterOffer.characterIds, selectedCharacterId) then
return nil, {
makeError("selection_not_in_replayed_character_offer", "$.state.selectedCharacterId", "선택 캐릭터가 결정적으로 재생한 후보에 포함되지 않습니다."),
}
end
return {
schemaVersion = SCHEMA_VERSION,
kind = KIND,
setupId = setupId,
phase = "battleReady",
rng = nextRng,
selectedCardIds = copyArray(selectedCardIds),
selectedCharacterId = selectedCharacterId,
battleSpec = buildBattleSpec(setupId, seed),
}, nil
end
local function advanceValidatedState(current, selectedCardId, pool)
-- current는 같은 choose 호출에서 validateAndReplay를 통과해 얻은
-- canonical snapshot이다. 외부/저장 값을 이 함수에 직접 넣지 않는다.
local selected = copyArray(current.selectedCardIds)
selected[#selected + 1] = selectedCardId
local counts = {}
for _, cardId in ipairs(selected) do
counts[cardId] = (counts[cardId] or 0) + 1
if counts[cardId] > MAX_COPIES then
return nil, {
makeError(
"card_copy_limit_exceeded",
"$.selectedCardIds[" .. #selected .. "]",
"같은 카드는 최대 2장까지 선택할 수 있습니다."
),
}
end
end
if #selected == TOTAL_DRAFTS then
return {
schemaVersion = SCHEMA_VERSION,
kind = KIND,
setupId = current.setupId,
phase = "deckComplete",
rng = {
seed = current.rng.seed,
cursor = current.rng.cursor,
},
selectedCardIds = selected,
}, nil
end
local nextRound = #selected + 1
local offer, nextRng, offerErrors = generateOffer(
current.setupId,
nextRound,
current.rng,
selected,
counts,
pool
)
if offerErrors then
return nil, offerErrors
end
return {
schemaVersion = SCHEMA_VERSION,
kind = KIND,
setupId = current.setupId,
phase = "deckDraft",
rng = nextRng,
selectedCardIds = selected,
offer = offer,
}, nil
end
local function validateStateShape(state, pool, characterPool)
local errors = {}
validateJson(state, "$.state", errors, {})
if type(state) ~= "table" or getmetatable(state) ~= nil then
return nil, errors
end
checkAllowedKeys(state, {
schemaVersion = true,
kind = true,
setupId = true,
phase = true,
rng = true,
selectedCardIds = true,
offer = true,
characterOffer = true,
selectedCharacterId = true,
battleSpec = true,
}, "$.state", errors)
if state.schemaVersion ~= SCHEMA_VERSION then
appendError(errors, "unsupported_schema_version", "$.state.schemaVersion", "지원하지 않는 게임 설정 스키마입니다.")
end
if state.kind ~= KIND then
appendError(errors, "invalid_state_kind", "$.state.kind", "게임 설정 상태 kind가 올바르지 않습니다.")
end
if not isRuntimeId(state.setupId) then
appendError(errors, "invalid_setup_id", "$.state.setupId", "setupId는 ASCII 런타임 ID여야 합니다.")
end
if state.phase ~= "deckDraft"
and state.phase ~= "deckComplete"
and state.phase ~= "characterSelect"
and state.phase ~= "battleReady" then
appendError(errors, "invalid_setup_phase", "$.state.phase", "지원하지 않는 게임 설정 phase입니다.")
end
if type(state.rng) ~= "table" or getmetatable(state.rng) ~= nil then
appendError(errors, "invalid_rng", "$.state.rng", "rng는 메타테이블이 없는 테이블이어야 합니다.")
else
checkAllowedKeys(state.rng, { seed = true, cursor = true }, "$.state.rng", errors)
if not isSafeInteger(state.rng.seed, 0) then
appendError(errors, "invalid_rng_seed", "$.state.rng.seed", "seed는 0 이상의 IEEE-754 안전 정수여야 합니다.")
end
if not isSafeInteger(state.rng.cursor, 0) then
appendError(errors, "invalid_rng_cursor", "$.state.rng.cursor", "cursor는 0 이상의 IEEE-754 안전 정수여야 합니다.")
end
end
local selectedLength = denseArrayLength(state.selectedCardIds, "$.state.selectedCardIds", errors)
local poolSet = {}
for _, cardId in ipairs(pool.cardIds) do
poolSet[cardId] = true
end
if selectedLength ~= nil then
if selectedLength > TOTAL_DRAFTS then
appendError(errors, "too_many_selected_cards", "$.state.selectedCardIds", "초기 덱은 10장을 초과할 수 없습니다.")
end
local counts = {}
for index = 1, selectedLength do
local cardId = state.selectedCardIds[index]
if not isAsciiId(cardId) then
appendError(errors, "invalid_selected_card_id", "$.state.selectedCardIds[" .. index .. "]", "선택한 카드 ID가 올바르지 않습니다.")
elseif not poolSet[cardId] then
appendError(errors, "unknown_selected_card", "$.state.selectedCardIds[" .. index .. "]", "현재 플레이어 카드 풀에 없는 카드입니다.")
else
counts[cardId] = (counts[cardId] or 0) + 1
if counts[cardId] > MAX_COPIES then
appendError(errors, "card_copy_limit_exceeded", "$.state.selectedCardIds[" .. index .. "]", "같은 카드는 최대 2장까지 선택할 수 있습니다.")
end
end
end
end
local characterPoolSet = {}
for _, characterId in ipairs(characterPool or {}) do
characterPoolSet[characterId] = true
end
if state.phase == "deckDraft" then
if selectedLength ~= nil and selectedLength >= TOTAL_DRAFTS then
appendError(errors, "invalid_draft_progress", "$.state.phase", "10장을 모두 선택한 상태는 deckComplete이어야 합니다.")
end
if type(state.offer) ~= "table" or getmetatable(state.offer) ~= nil then
appendError(errors, "missing_draft_offer", "$.state.offer", "deckDraft 상태에는 현재 제안이 필요합니다.")
else
checkAllowedKeys(state.offer, {
round = true,
cardIds = true,
interactionToken = true,
}, "$.state.offer", errors)
if not isSafeInteger(state.offer.round, 1) or state.offer.round > TOTAL_DRAFTS then
appendError(errors, "invalid_offer_round", "$.state.offer.round", "제안 라운드는 1부터 10 사이의 정수여야 합니다.")
end
if selectedLength ~= nil and state.offer.round ~= selectedLength + 1 then
appendError(errors, "offer_round_mismatch", "$.state.offer.round", "제안 라운드가 선택 진행도와 일치하지 않습니다.")
end
local offerLength = denseArrayLength(state.offer.cardIds, "$.state.offer.cardIds", errors)
if offerLength ~= nil then
if offerLength ~= OFFER_SIZE then
appendError(errors, "invalid_offer_size", "$.state.offer.cardIds", "드래프트 제안은 서로 다른 카드 3장이어야 합니다.")
end
local seen = {}
for index = 1, offerLength do
local cardId = state.offer.cardIds[index]
if not isAsciiId(cardId) or not poolSet[cardId] then
appendError(errors, "invalid_offer_card", "$.state.offer.cardIds[" .. index .. "]", "제안 카드가 현재 플레이어 카드 풀에 없습니다.")
elseif seen[cardId] then
appendError(errors, "duplicate_offer_card", "$.state.offer.cardIds[" .. index .. "]", "한 제안에 같은 카드를 두 번 넣을 수 없습니다.")
end
seen[cardId] = true
end
end
if not isInteractionToken(state.offer.interactionToken) then
appendError(errors, "invalid_interaction_token", "$.state.offer.interactionToken", "interactionToken 형식이 올바르지 않습니다.")
end
end
if rawget(state, "characterOffer") ~= nil
or rawget(state, "selectedCharacterId") ~= nil
or rawget(state, "battleSpec") ~= nil then
appendError(errors, "character_fields_not_allowed", "$.state", "deckDraft 상태에는 캐릭터 선택 필드를 저장하지 않습니다.")
end
elseif state.phase == "deckComplete" then
if selectedLength ~= nil and selectedLength ~= TOTAL_DRAFTS then
appendError(errors, "invalid_complete_progress", "$.state.selectedCardIds", "deckComplete 상태에는 카드가 정확히 10장 있어야 합니다.")
end
if rawget(state, "offer") ~= nil then
appendError(errors, "offer_not_allowed", "$.state.offer", "deckComplete 상태에는 제안을 저장하지 않습니다.")
end
if rawget(state, "characterOffer") ~= nil
or rawget(state, "selectedCharacterId") ~= nil
or rawget(state, "battleSpec") ~= nil then
appendError(errors, "character_fields_not_allowed", "$.state", "deckComplete 상태에는 캐릭터 선택 결과를 저장하지 않습니다.")
end
elseif state.phase == "characterSelect" then
if selectedLength ~= nil and selectedLength ~= TOTAL_DRAFTS then
appendError(errors, "invalid_character_select_progress", "$.state.selectedCardIds", "characterSelect 상태에는 카드가 정확히 10장 있어야 합니다.")
end
if rawget(state, "offer") ~= nil then
appendError(errors, "offer_not_allowed", "$.state.offer", "characterSelect 상태에는 카드 제안을 저장하지 않습니다.")
end
if rawget(state, "selectedCharacterId") ~= nil or rawget(state, "battleSpec") ~= nil then
appendError(errors, "battle_fields_not_allowed", "$.state", "characterSelect 상태에는 전투 준비 결과를 저장하지 않습니다.")
end
if type(state.characterOffer) ~= "table" or getmetatable(state.characterOffer) ~= nil then
appendError(errors, "missing_character_offer", "$.state.characterOffer", "characterSelect 상태에는 캐릭터 후보가 필요합니다.")
else
checkAllowedKeys(state.characterOffer, {
characterIds = true,
interactionToken = true,
}, "$.state.characterOffer", errors)
local characterLength = denseArrayLength(
state.characterOffer.characterIds,
"$.state.characterOffer.characterIds",
errors
)
if characterLength ~= nil then
if characterLength ~= CHARACTER_OFFER_SIZE then
appendError(errors, "invalid_character_offer_size", "$.state.characterOffer.characterIds", "캐릭터 후보는 서로 다른 3명이어야 합니다.")
end
local seen = {}
for index = 1, characterLength do
local characterId = state.characterOffer.characterIds[index]
if not isAsciiId(characterId) or not characterPoolSet[characterId] then
appendError(errors, "invalid_offered_character", "$.state.characterOffer.characterIds[" .. index .. "]", "후보가 현재 캐릭터 풀에 없습니다.")
elseif seen[characterId] then
appendError(errors, "duplicate_offered_character", "$.state.characterOffer.characterIds[" .. index .. "]", "한 제안에 같은 캐릭터를 두 번 넣을 수 없습니다.")
end
seen[characterId] = true
end
end
if not isCharacterInteractionToken(state.characterOffer.interactionToken) then
appendError(errors, "invalid_character_interaction_token", "$.state.characterOffer.interactionToken", "캐릭터 interactionToken 형식이 올바르지 않습니다.")
end
end
elseif state.phase == "battleReady" then
if selectedLength ~= nil and selectedLength ~= TOTAL_DRAFTS then
appendError(errors, "invalid_battle_ready_progress", "$.state.selectedCardIds", "battleReady 상태에는 카드가 정확히 10장 있어야 합니다.")
end
if rawget(state, "offer") ~= nil or rawget(state, "characterOffer") ~= nil then
appendError(errors, "offer_not_allowed", "$.state", "battleReady 상태에는 제안을 저장하지 않습니다.")
end
if not isAsciiId(state.selectedCharacterId) or not characterPoolSet[state.selectedCharacterId] then
appendError(errors, "invalid_selected_character", "$.state.selectedCharacterId", "선택 캐릭터가 현재 캐릭터 풀에 없습니다.")
end
if type(state.battleSpec) ~= "table" or getmetatable(state.battleSpec) ~= nil then
appendError(errors, "invalid_battle_spec", "$.state.battleSpec", "battleSpec은 메타테이블이 없는 객체여야 합니다.")
else
checkAllowedKeys(state.battleSpec, {
battleId = true,
seed = true,
}, "$.state.battleSpec", errors)
if not isRuntimeId(state.battleSpec.battleId) then
appendError(errors, "invalid_battle_id", "$.state.battleSpec.battleId", "battleId는 ASCII 런타임 ID여야 합니다.")
end
if not isSafeInteger(state.battleSpec.seed, 1) or state.battleSpec.seed > BATTLE_SEED_MODULUS then
appendError(errors, "invalid_battle_seed", "$.state.battleSpec.seed", "전투 seed는 1부터 2147483646 사이의 정수여야 합니다.")
end
end
end
if #errors > 0 then
return nil, errors
end
return selectedLength, nil
end
local function validateAndReplay(state, staticInput)
local pool, poolErrors = buildPlayerPool(staticInput)
if poolErrors then
return nil, poolErrors
end
local characterPool = nil
if type(state) == "table"
and (state.phase == "characterSelect" or state.phase == "battleReady") then
local characterErrors
characterPool, characterErrors = buildCharacterPool(staticInput)
if characterErrors then
return nil, characterErrors
end
end
local _, shapeErrors = validateStateShape(state, pool, characterPool)
if shapeErrors then
return nil, shapeErrors
end
local expected, replayErrors = replayForPhase(
state.setupId,
state.rng.seed,
state.selectedCardIds,
pool,
state.phase,
characterPool,
state.selectedCharacterId
)
if replayErrors then
return nil, replayErrors
end
if not deepEqual(state, expected) then
return nil, {
makeError("setup_state_replay_mismatch", "$.state", "저장된 상태가 seed와 선택 이력으로 재생성한 RNG·제안·토큰과 일치하지 않습니다."),
}
end
return expected, nil, pool, characterPool
end
local function validateSpec(spec)
local errors = {}
validateJson(spec, "$.spec", errors, {})
if type(spec) ~= "table" or getmetatable(spec) ~= nil then
return errors
end
checkAllowedKeys(spec, { setupId = true, seed = true }, "$.spec", errors)
if not isRuntimeId(spec.setupId) then
appendError(errors, "invalid_setup_id", "$.spec.setupId", "setupId는 ASCII 런타임 ID여야 합니다.")
end
if not isSafeInteger(spec.seed, 0) then
appendError(errors, "invalid_seed", "$.spec.seed", "seed는 0 이상의 IEEE-754 안전 정수여야 합니다.")
end
return errors
end
local function validateCommand(command)
local errors = {}
validateJson(command, "$.command", errors, {})
if type(command) ~= "table" or getmetatable(command) ~= nil then
return errors
end
checkAllowedKeys(command, { cardId = true, interactionToken = true }, "$.command", errors)
if not isAsciiId(command.cardId) then
appendError(errors, "invalid_command_card_id", "$.command.cardId", "cardId는 ASCII 카드 ID여야 합니다.")
end
if not isInteractionToken(command.interactionToken) then
appendError(errors, "invalid_command_token", "$.command.interactionToken", "interactionToken 형식이 올바르지 않습니다.")
end
return errors
end
local function validateCharacterCommand(command)
local errors = {}
validateJson(command, "$.command", errors, {})
if type(command) ~= "table" or getmetatable(command) ~= nil then
return errors
end
checkAllowedKeys(command, { characterId = true, interactionToken = true }, "$.command", errors)
if not isAsciiId(command.characterId) then
appendError(errors, "invalid_command_character_id", "$.command.characterId", "characterId는 ASCII 캐릭터 ID여야 합니다.")
end
if not isCharacterInteractionToken(command.interactionToken) then
appendError(errors, "invalid_command_token", "$.command.interactionToken", "캐릭터 interactionToken 형식이 올바르지 않습니다.")
end
return errors
end
local function startSetup(spec, staticInput)
local specErrors = validateSpec(spec)
if #specErrors > 0 then
return failure(specErrors)
end
local pool, poolErrors = buildPlayerPool(staticInput)
if poolErrors then
return failure(poolErrors)
end
local state, replayErrors = replay(spec.setupId, spec.seed, {}, pool)
if replayErrors then
return failure(replayErrors)
end
return success(state)
end
local function validateSetup(state, staticInput)
local expected, errors = validateAndReplay(state, staticInput)
if errors then
return failure(errors)
end
return success(expected)
end
local function chooseCard(state, command, staticInput)
local commandErrors = validateCommand(command)
if #commandErrors > 0 then
return failure(commandErrors)
end
local current, stateErrors, pool = validateAndReplay(state, staticInput)
if stateErrors then
return failure(stateErrors)
end
if current.phase ~= "deckDraft" then
local stateCopy, cloneError = cloneChecked(state, "$.state")
if cloneError then
return failure({ cloneError })
end
return success(stateCopy, false, true)
end
if command.interactionToken ~= current.offer.interactionToken then
local stateCopy, cloneError = cloneChecked(state, "$.state")
if cloneError then
return failure({ cloneError })
end
return success(stateCopy, false, true)
end
if not contains(current.offer.cardIds, command.cardId) then
return failure({
makeError("card_not_in_current_offer", "$.command.cardId", "현재 드래프트 제안에 없는 카드입니다."),
})
end
local nextState, advanceErrors = advanceValidatedState(current, command.cardId, pool)
if advanceErrors then
return failure(advanceErrors)
end
return success(nextState, true, false)
end
local function beginCharacterSelect(state, staticInput)
local current, stateErrors = validateAndReplay(state, staticInput)
if stateErrors then
return failure(stateErrors)
end
if current.phase ~= "deckComplete" then
return failure({
makeError("deck_not_complete", "$.state.phase", "캐릭터 선택은 deckComplete 상태에서만 시작할 수 있습니다."),
})
end
local characterPool, characterErrors = buildCharacterPool(staticInput)
if characterErrors then
return failure(characterErrors)
end
local characterOffer, nextRng, offerErrors = generateCharacterOffer(
current.setupId,
current.rng,
characterPool
)
if offerErrors then
return failure(offerErrors)
end
return success({
schemaVersion = SCHEMA_VERSION,
kind = KIND,
setupId = current.setupId,
phase = "characterSelect",
rng = nextRng,
selectedCardIds = copyArray(current.selectedCardIds),
characterOffer = characterOffer,
}, true, false)
end
local function chooseCharacter(state, command, staticInput)
local commandErrors = validateCharacterCommand(command)
if #commandErrors > 0 then
return failure(commandErrors)
end
local current, stateErrors = validateAndReplay(state, staticInput)
if stateErrors then
return failure(stateErrors)
end
if current.phase == "battleReady" then
return success(current, false, true)
end
if current.phase ~= "characterSelect" then
return failure({
makeError("character_selection_not_started", "$.state.phase", "현재 상태에서는 캐릭터를 선택할 수 없습니다."),
})
end
if command.interactionToken ~= current.characterOffer.interactionToken then
return success(current, false, true)
end
if not contains(current.characterOffer.characterIds, command.characterId) then
return failure({
makeError("character_not_in_current_offer", "$.command.characterId", "현재 제안에 없는 캐릭터입니다."),
})
end
return success({
schemaVersion = SCHEMA_VERSION,
kind = KIND,
setupId = current.setupId,
phase = "battleReady",
rng = {
seed = current.rng.seed,
cursor = current.rng.cursor,
},
selectedCardIds = copyArray(current.selectedCardIds),
selectedCharacterId = command.characterId,
battleSpec = buildBattleSpec(current.setupId, current.rng.seed),
}, true, false)
end
local arguments = { ... }
local actions = {
start = startSetup,
choose = chooseCard,
beginCharacterSelect = beginCharacterSelect,
chooseCharacter = chooseCharacter,
validate = validateSetup,
}
local handler = actions[action]
if not handler then
return failure({
makeError("unknown_action", "$.action", "지원하지 않는 게임 설정 작업입니다: " .. tostring(action)),
})
end
return handler(arguments[1], arguments[2], arguments[3])
end)
#38gameSetupController.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local MAX_SAFE_INTEGER = 9007199254740991
local AUTHORITY_KEY = "gameSetupV1.authority"
local VIEW_NAME = "gameSetupView"
local RUN_AUTHORITY_KEY = "runProgressionV1.authority"
local RUN_VIEW_NAME = "runProgressionView"
local READY_NAME = "gameSetupReady"
local UI_NAME = "🔯🔯🔯"
local UI_SHELL_NAME = "helltrainUiShellV1"
local UI_SHELL_REVISION_NAME = "helltrainUiShellRevision"
local UI_SHELL_REVISION = "sidebar-e3f104ae8f3037cd"
local DIAGNOSTIC_SCOPE = "helltrain.gameSetupController"
local arguments = { ... }
local argumentCount = select("#", ...)
local function diagnosticText(value)
return tostring(value):gsub("[\r\n]+", " ")
end
local function emitDiagnostic(depth, event, fields)
if type(DEBUG) ~= "number" or depth > DEBUG then
return
end
if type(print) ~= "function" then
return
end
local parts = {
"[" .. DIAGNOSTIC_SCOPE .. "]",
"depth=" .. tostring(depth),
"event=" .. diagnosticText(event),
}
for _, key in ipairs({ "action", "errorCount", "code", "path", "message" }) do
local value = type(fields) == "table" and fields[key] or nil
if value ~= nil then
parts[#parts + 1] = key .. "=" .. diagnosticText(value)
end
end
pcall(print, table.concat(parts, " | "))
for index, item in ipairs(type(fields) == "table" and type(fields.errors) == "table" and fields.errors or {}) do
local errorParts = {
"[" .. DIAGNOSTIC_SCOPE .. "]",
"depth=" .. tostring(depth),
"event=request_error",
"index=" .. tostring(index),
}
for _, key in ipairs({ "code", "path", "message" }) do
if type(item) == "table" and item[key] ~= nil then
errorParts[#errorParts + 1] = key .. "=" .. diagnosticText(item[key])
end
end
pcall(print, table.concat(errorParts, " | "))
end
end
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
errors = errors,
}
end
local function success(fields)
local report = {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
}
for key, value in pairs(fields or {}) do
report[key] = value
end
return report
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isSafeInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and math.abs(value) <= MAX_SAFE_INTEGER
and (minimum == nil or value >= minimum)
end
local function objectPath(path, key)
if type(key) == "string" and string.match(key, "^[A-Za-z_][A-Za-z0-9_]*$") then
return path .. "." .. key
end
return path .. "[" .. string.format("%q", tostring(key)) .. "]"
end
local function cloneJson(value, path, active)
local valueType = type(value)
if valueType == "nil" or valueType == "string" or valueType == "boolean" then
return value, nil
end
if valueType == "number" then
if not isFinite(value) then
return nil, makeError("non_finite_number", path, "유한하지 않은 숫자는 컨트롤러 데이터에 사용할 수 없습니다.")
end
return value, nil
end
if valueType ~= "table" then
return nil, makeError(
"unsupported_type",
path,
"JSON 컨트롤러 데이터에 사용할 수 없는 자료형입니다: " .. valueType
)
end
if getmetatable(value) ~= nil then
return nil, makeError("metatable_not_allowed", path, "컨트롤러 데이터에는 메타테이블을 사용할 수 없습니다.")
end
active = active or {}
if active[value] then
return nil, makeError("circular_reference", path, "순환 참조는 컨트롤러 데이터에 사용할 수 없습니다.")
end
active[value] = true
local hasNumeric = false
local hasString = false
local numericCount = 0
local maximum = 0
for key in pairs(value) do
if type(key) == "number" then
hasNumeric = true
if not isSafeInteger(key, 1) then
active[value] = nil
return nil, makeError("invalid_array_index", path, "배열 인덱스는 1 이상의 정수여야 합니다.")
end
numericCount = numericCount + 1
if key > maximum then
maximum = key
end
elseif type(key) == "string" then
hasString = true
else
active[value] = nil
return nil, makeError("invalid_object_key", path, "객체 키는 문자열이어야 합니다.")
end
end
if hasNumeric and hasString then
active[value] = nil
return nil, makeError("mixed_table", path, "숫자 인덱스와 문자열 키를 함께 사용할 수 없습니다.")
end
if hasNumeric and numericCount ~= maximum then
active[value] = nil
return nil, makeError("sparse_array", path, "배열 인덱스는 1부터 빈틈없이 이어져야 합니다.")
end
local copy = {}
if hasNumeric then
for index = 1, maximum do
local itemCopy, itemError = cloneJson(value[index], path .. "[" .. index .. "]", active)
if itemError then
active[value] = nil
return nil, itemError
end
copy[index] = itemCopy
end
else
for key, item in pairs(value) do
local itemCopy, itemError = cloneJson(item, objectPath(path, key), active)
if itemError then
active[value] = nil
return nil, itemError
end
copy[key] = itemCopy
end
end
active[value] = nil
return copy, nil
end
local function deepEqual(left, right, active)
if type(left) ~= type(right) then
return false
end
if type(left) ~= "table" then
return left == right
end
if getmetatable(left) ~= nil or getmetatable(right) ~= nil then
return false
end
active = active or {}
if active[left] ~= nil then
return active[left] == right
end
active[left] = right
for key, value in pairs(left) do
if not deepEqual(value, right[key], active) then
active[left] = nil
return false
end
end
for key in pairs(right) do
if left[key] == nil then
active[left] = nil
return false
end
end
active[left] = nil
return true
end
local function appendNestedErrors(target, moduleName, report)
if type(report) == "table" and type(report.errors) == "table" then
for index, item in ipairs(report.errors) do
if type(item) == "table"
and type(item.code) == "string"
and type(item.path) == "string"
and type(item.message) == "string" then
target[#target + 1] = makeError(item.code, item.path, item.message)
else
target[#target + 1] = makeError(
"invalid_nested_error",
"$.runtime." .. moduleName .. ".errors[" .. index .. "]",
"하위 모듈 오류 항목이 올바른 구조가 아닙니다."
)
end
end
end
if #target == 0 then
target[1] = makeError(
"module_failed_without_errors",
"$.runtime." .. moduleName,
moduleName .. "이 오류 상세 없이 실패했습니다."
)
end
end
local function validateErrorArray(moduleName, errors)
if type(errors) ~= "table" or getmetatable(errors) ~= nil then
return false
end
local count = 0
local maximum = 0
for key, item in pairs(errors) do
if type(key) ~= "number" or not isSafeInteger(key, 1) then
return false
end
count = count + 1
if key > maximum then maximum = key end
if type(item) ~= "table"
or getmetatable(item) ~= nil
or type(item.code) ~= "string"
or type(item.path) ~= "string"
or type(item.message) ~= "string" then
return false
end
end
return count == maximum
end
local function callModule(moduleName, moduleAction, ...)
if type(runScript) ~= "function" then
return nil, {
makeError("runtime_unavailable", "$.runtime", "runScript 실행기를 찾을 수 없습니다."),
}
end
local ok, report = pcall(runScript, triggerId, moduleName, moduleAction, ...)
if not ok then
return nil, {
makeError(
"module_call_error",
"$.runtime." .. moduleName,
moduleName .. "." .. moduleAction .. " 호출 중 오류가 발생했습니다: " .. tostring(report)
),
}
end
if type(report) ~= "table" or getmetatable(report) ~= nil then
return nil, {
makeError(
"invalid_module_result",
"$.runtime." .. moduleName,
moduleName .. "." .. moduleAction .. "이 일반 테이블 결과를 반환하지 않았습니다."
),
}
end
local isBridge = moduleName == "dataBridge"
local versionOk = report.schemaVersion == SCHEMA_VERSION
or (isBridge and report.schemaVersion == nil and report.wireFormat == "cbs-json-nodes-v1")
local errorsOk = validateErrorArray(moduleName, report.errors)
or (isBridge and report.ok == true and report.errors == nil)
if (report.ok ~= true and report.ok ~= false) or not versionOk or not errorsOk then
return nil, {
makeError(
"invalid_module_envelope",
"$.runtime." .. moduleName,
moduleName .. "." .. moduleAction .. " 결과 envelope가 올바르지 않습니다."
),
}
end
if report.ok ~= true then
local errors = {}
appendNestedErrors(errors, moduleName, report)
return nil, errors
end
if type(report.errors) == "table" and #report.errors ~= 0 then
return nil, {
makeError(
"success_with_errors",
"$.runtime." .. moduleName .. ".errors",
"성공한 하위 모듈 결과에 오류가 포함되어 있습니다."
),
}
end
return report, nil
end
local function loadStaticData()
local report, errors = callModule("staticData", "loadAll")
if errors then return nil, errors end
if type(report.data) ~= "table" or getmetatable(report.data) ~= nil then
return nil, {
makeError("missing_static_data", "$.runtime.staticData.data", "검증된 전체 정적 데이터가 없습니다."),
}
end
return report.data, nil
end
local function readAuthority(required)
if type(HostCompat) ~= "table" or type(HostCompat.readState) ~= "function" then
return nil, {
makeError("state_read_unavailable", "$.host.getState", "상태 읽기 호환 함수를 찾을 수 없습니다."),
}
end
local ok, value = pcall(HostCompat.readState, triggerId, AUTHORITY_KEY)
if not ok then
return nil, {
makeError("state_read_failed", "$.state.authority", "게임 설정 상태를 읽지 못했습니다: " .. tostring(value)),
}
end
if value == nil then
if required then
return nil, {
makeError("missing_authority", "$.state.authority", "먼저 게임 설정을 시작해야 합니다."),
}
end
return nil, nil
end
local copy, copyError = cloneJson(value, "$.state.authority")
if copyError then return nil, { copyError } end
return copy, nil
end
local function readRunAuthority(required)
if type(HostCompat) ~= "table" or type(HostCompat.readState) ~= "function" then
return nil, {
makeError("state_read_unavailable", "$.host.getState", "상태 읽기 호환 함수를 찾을 수 없습니다."),
}
end
local ok, value = pcall(HostCompat.readState, triggerId, RUN_AUTHORITY_KEY)
if not ok then
return nil, {
makeError("state_read_failed", "$.state.runAuthority", "진행 상태를 읽지 못했습니다: " .. tostring(value)),
}
end
if value == nil then
if required then
return nil, {
makeError("missing_run_authority", "$.state.runAuthority", "전투 후 진행 상태가 없습니다."),
}
end
return nil, nil
end
local copy, copyError = cloneJson(value, "$.state.runAuthority")
if copyError then return nil, { copyError } end
return copy, nil
end
local function generateSetupSeed()
if type(cbs) ~= "function" then
return nil, {
makeError("cbs_unavailable", "$.host.cbs", "초기 seed를 만들 CBS 함수를 찾을 수 없습니다."),
}
end
-- getLoreBooks가 Lua 로어를 반환하기 전에 CBS를 평가하므로,
-- 소스에 완성된 동적 매크로를 두면 컴파일 캐시가 첫 seed를
-- 고정할 수 있다. 호스트 cbs 호출 직전에만 문자열을 조립한다.
local seedMacro = string.rep("{", 2)
.. "randint::1::2147483646"
.. string.rep("}", 2)
local ok, rendered = pcall(cbs, seedMacro)
if not ok then
return nil, {
makeError("seed_generation_failed", "$.host.cbs", "초기 seed 생성에 실패했습니다: " .. tostring(rendered)),
}
end
if type(rendered) ~= "string" or string.match(rendered, "^[1-9][0-9]*$") == nil then
return nil, {
makeError(
"invalid_generated_seed",
"$.host.cbs.result",
"CBS randint 결과는 앞뒤 문자가 없는 양의 십진 정수여야 합니다."
),
}
end
local seed = tonumber(rendered)
if not isSafeInteger(seed, 1) or seed > 2147483646 then
return nil, {
makeError(
"generated_seed_out_of_range",
"$.host.cbs.result",
"초기 seed는 1부터 2147483646 사이의 정수여야 합니다."
),
}
end
return seed, nil
end
local function loadSetupUi(phase)
if type(loadLores) ~= "function" then
return nil, {
makeError("lore_loader_unavailable", "$.host.loadLores", "UI 로어북을 읽을 loadLores 함수를 찾을 수 없습니다."),
}
end
local loreName
local lorePath
if phase == "deckDraft" or phase == "deckComplete" then
loreName = "cardDraft.html"
lorePath = "$.lore.cardDraft"
elseif phase == "characterSelect" then
loreName = "characterSelect.html"
lorePath = "$.lore.characterSelect"
else
return nil, {
makeError("invalid_setup_ui_phase", "$.state.phase", "이 설정 phase에는 setup UI를 게시할 수 없습니다."),
}
end
local loadOk, setupUi = pcall(loadLores, triggerId, loreName)
if not loadOk then
return nil, {
makeError("lore_load_failed", lorePath, loreName .. "을 읽지 못했습니다: " .. tostring(setupUi)),
}
end
if type(setupUi) ~= "string" or setupUi == "" then
return nil, {
makeError("missing_lore", lorePath, loreName .. " 로어북 내용이 없습니다."),
}
end
return setupUi, nil
end
local function loadRunUi()
if type(loadLores) ~= "function" then
return nil, {
makeError("lore_loader_unavailable", "$.host.loadLores", "UI 로어북을 읽을 loadLores 함수를 찾을 수 없습니다."),
}
end
local loadOk, runUi = pcall(loadLores, triggerId, "postBattle.html")
if not loadOk then
return nil, {
makeError("lore_load_failed", "$.lore.postBattle", "postBattle.html을 읽지 못했습니다: " .. tostring(runUi)),
}
end
if type(runUi) ~= "string" or runUi == "" then
return nil, {
makeError("missing_lore", "$.lore.postBattle", "postBattle.html 로어북 내용이 없습니다."),
}
end
return runUi, nil
end
local function permitCanonicalOperation(purpose, viewName)
if purpose == "gameSetupViewCanonicalV1" then
return true
end
if purpose == "runProgressionViewCanonicalV1" then
return true
end
if purpose == "battleControllerCanonicalSetupV1"
or purpose == "battleControllerCanonicalRunV1" then
return true
end
return purpose == "dataBridgeCanonicalV1"
and (viewName == VIEW_NAME or viewName == RUN_VIEW_NAME)
end
local function buildTarget(state, staticData)
local stateCopy, stateCopyError = cloneJson(state, "$.target.state")
if stateCopyError then return nil, { stateCopyError } end
-- stateCopy는 이 controller transaction에서 gameSetup.start/choose/validate가
-- 반환한 canonical state이다. 버튼 문자열로 위조할 수 없는
-- 함수 capability를 넘겨 View의 전체 authority replay만 중복 제거한다.
local viewReport, viewErrors = callModule(
"gameSetupView",
"_buildCanonical",
stateCopy,
staticData,
permitCanonicalOperation
)
if viewErrors then return nil, viewErrors end
if type(viewReport.view) ~= "table" or getmetatable(viewReport.view) ~= nil then
return nil, {
makeError("missing_setup_view", "$.runtime.gameSetupView.view", "gameSetupView 생성 결과에 View가 없습니다."),
}
end
local viewCopy, viewCopyError = cloneJson(viewReport.view, "$.target.view")
if viewCopyError then return nil, { viewCopyError } end
return {
state = stateCopy,
view = viewCopy,
}, nil
end
local function buildRunTarget(runState, setupState, staticData)
local runCopy, runCopyError = cloneJson(runState, "$.target.runState")
if runCopyError then return nil, { runCopyError } end
local setupCopy, setupCopyError = cloneJson(setupState, "$.target.setupState")
if setupCopyError then return nil, { setupCopyError } end
local viewReport, viewErrors = callModule(
"runProgressionView",
"_buildCanonical",
runCopy,
setupCopy,
staticData,
permitCanonicalOperation
)
if viewErrors then return nil, viewErrors end
if type(viewReport.view) ~= "table" or getmetatable(viewReport.view) ~= nil then
return nil, {
makeError(
"missing_run_progression_view",
"$.runtime.runProgressionView.view",
"runProgressionView 생성 결과에 View가 없습니다."
),
}
end
local viewCopy, viewCopyError = cloneJson(viewReport.view, "$.target.runView")
if viewCopyError then return nil, { viewCopyError } end
return {
state = runCopy,
setupState = setupCopy,
view = viewCopy,
}, nil
end
local function preflightHost(writeAuthority)
local required = {
{ name = "writeChatVar", value = type(HostCompat) == "table" and HostCompat.writeChatVar or nil },
{ name = "getChatVar", value = getChatVar },
}
if writeAuthority then
required[#required + 1] = { name = "writeState", value = type(HostCompat) == "table" and HostCompat.writeState or nil }
required[#required + 1] = { name = "readState", value = type(HostCompat) == "table" and HostCompat.readState or nil }
end
local errors = {}
for _, entry in ipairs(required) do
if type(entry.value) ~= "function" then
errors[#errors + 1] = makeError(
"host_function_unavailable",
"$.host." .. entry.name,
entry.name .. " 호스트 함수를 찾을 수 없습니다."
)
end
end
if #errors > 0 then return errors end
return nil
end
local function writeChatVarStored(name, value, path)
local writeOk, writeError = pcall(HostCompat.writeChatVar, triggerId, name, value)
if not writeOk then
return {
makeError("chat_var_write_failed", path, "채팅 변수 쓰기에 실패했습니다: " .. tostring(writeError)),
}
end
return nil
end
local function ensureUiShell()
local revisionOk, currentRevision = pcall(getChatVar, triggerId, UI_SHELL_REVISION_NAME)
if not revisionOk then
return {
makeError("chat_var_verify_failed", "$.chatVar.uiShellRevision", "UI shell revision을 읽지 못했습니다: " .. tostring(currentRevision)),
}
end
local shellOk, currentShell = pcall(getChatVar, triggerId, UI_SHELL_NAME)
if not shellOk then
return {
makeError("chat_var_verify_failed", "$.chatVar.uiShell", "UI shell을 읽지 못했습니다: " .. tostring(currentShell)),
}
end
if currentRevision == UI_SHELL_REVISION
and type(currentShell) == "string"
and currentShell ~= "" then
return nil
end
local loadOk, sideBar = pcall(loadLores, triggerId, "sideBar.html")
if not loadOk then
return {
makeError("lore_load_failed", "$.lore.sideBar", "sideBar.html을 읽지 못했습니다: " .. tostring(sideBar)),
}
end
if type(sideBar) ~= "string" or sideBar == "" then
return {
makeError("missing_lore", "$.lore.sideBar", "sideBar.html 로어북 내용이 없습니다."),
}
end
local shellErrors = writeChatVarStored(UI_SHELL_NAME, sideBar, "$.chatVar.uiShell")
if shellErrors then return shellErrors end
return writeChatVarStored(
UI_SHELL_REVISION_NAME,
UI_SHELL_REVISION,
"$.chatVar.uiShellRevision"
)
end
local function verifyExpectedAuthority(expectedAuthority, expectMissing)
local beforeOk, before = pcall(HostCompat.readState, triggerId, AUTHORITY_KEY)
if not beforeOk then
return {
makeError(
"state_verify_read_failed",
"$.state.authority",
"권위 상태 쓰기 직전 현재 값을 읽지 못했습니다: " .. tostring(before)
),
}
end
if expectMissing == true then
if before ~= nil then
return {
makeError(
"authority_concurrent_change",
"$.state.authority",
"새 설정을 저장하기 전에 다른 요청이 권위 상태를 만들었습니다."
),
}
end
elseif type(expectedAuthority) ~= "table" or not deepEqual(before, expectedAuthority) then
return {
makeError(
"authority_concurrent_change",
"$.state.authority",
"설정 전이를 저장하기 전에 권위 상태가 다른 요청에 의해 변경되었습니다."
),
}
end
return nil
end
local function writeAuthorityVerified(state, expectedAuthority, expectMissing)
local concurrentErrors = verifyExpectedAuthority(expectedAuthority, expectMissing)
if concurrentErrors then return concurrentErrors end
local storedCopy, copyError = cloneJson(state, "$.state.authority")
if copyError then return { copyError } end
local writeOk, writeError = pcall(HostCompat.writeState, triggerId, AUTHORITY_KEY, storedCopy)
if not writeOk then
return {
makeError("state_write_failed", "$.state.authority", "게임 설정 상태 저장에 실패했습니다: " .. tostring(writeError)),
}
end
local readOk, stored = pcall(HostCompat.readState, triggerId, AUTHORITY_KEY)
if not readOk then
return {
makeError("state_verify_read_failed", "$.state.authority", "쓰기 뒤 게임 설정 상태를 읽지 못했습니다: " .. tostring(stored)),
}
end
if not deepEqual(storedCopy, stored) then
return {
makeError("state_write_not_persisted", "$.state.authority", "쓰기 뒤 읽은 상태가 저장하려던 상태와 다릅니다."),
}
end
-- 동일 transaction에서 gameSetup이 만든 canonical snapshot과
-- host readback이 exact-equal이므로 또 전체 replay할 필요는 없다.
-- 다음 이벤트에서 저장소를 다시 읽을 때는 다시 검증한다.
return nil
end
local function verifyExpectedRunAuthority(expectedAuthority, expectMissing)
local beforeOk, before = pcall(HostCompat.readState, triggerId, RUN_AUTHORITY_KEY)
if not beforeOk then
return {
makeError(
"state_verify_read_failed",
"$.state.runAuthority",
"진행 상태 쓰기 직전 현재 값을 읽지 못했습니다: " .. tostring(before)
),
}
end
if expectMissing == true then
if before ~= nil then
return {
makeError(
"run_authority_concurrent_change",
"$.state.runAuthority",
"첫 전투 정산을 저장하기 전에 다른 요청이 진행 상태를 만들었습니다."
),
}
end
elseif type(expectedAuthority) ~= "table" or not deepEqual(before, expectedAuthority) then
return {
makeError(
"run_authority_concurrent_change",
"$.state.runAuthority",
"진행 전이를 저장하기 전에 상태가 다른 요청에 의해 변경되었습니다."
),
}
end
return nil
end
local function writeRunAuthorityVerified(state, expectedAuthority, expectMissing)
local concurrentErrors = verifyExpectedRunAuthority(expectedAuthority, expectMissing)
if concurrentErrors then return concurrentErrors end
local storedCopy, copyError = cloneJson(state, "$.state.runAuthority")
if copyError then return { copyError } end
local writeOk, writeError = pcall(HostCompat.writeState, triggerId, RUN_AUTHORITY_KEY, storedCopy)
if not writeOk then
return {
makeError("state_write_failed", "$.state.runAuthority", "진행 상태 저장에 실패했습니다: " .. tostring(writeError)),
}
end
local readOk, stored = pcall(HostCompat.readState, triggerId, RUN_AUTHORITY_KEY)
if not readOk then
return {
makeError("state_verify_read_failed", "$.state.runAuthority", "쓰기 뒤 진행 상태를 읽지 못했습니다: " .. tostring(stored)),
}
end
if not deepEqual(storedCopy, stored) then
return {
makeError("state_write_not_persisted", "$.state.runAuthority", "쓰기 뒤 읽은 진행 상태가 저장하려던 상태와 다릅니다."),
}
end
return nil
end
local function beginPublish(writeAuthority, expectedAuthority, expectMissing)
local hostErrors = preflightHost(writeAuthority)
if hostErrors then return failure(hostErrors) end
if writeAuthority then
local concurrentErrors = verifyExpectedAuthority(expectedAuthority, expectMissing)
if concurrentErrors then return failure(concurrentErrors) end
end
local updatingErrors = writeChatVarStored(READY_NAME, "updating", "$.chatVar.gameSetupReady")
if updatingErrors then return failure(updatingErrors) end
return nil
end
local function beginRunPublish(writeAuthority, expectedAuthority, expectMissing)
local hostErrors = preflightHost(writeAuthority)
if hostErrors then return failure(hostErrors) end
if writeAuthority then
local concurrentErrors = verifyExpectedRunAuthority(expectedAuthority, expectMissing)
if concurrentErrors then return failure(concurrentErrors) end
end
local updatingErrors = writeChatVarStored(READY_NAME, "updating", "$.chatVar.gameSetupReady")
if updatingErrors then return failure(updatingErrors) end
return nil
end
local function publishState(
state,
writeAuthority,
actionName,
applied,
stale,
staticData,
expectedAuthority,
expectMissing
)
if writeAuthority then
local authorityErrors = writeAuthorityVerified(
state,
expectedAuthority,
expectMissing
)
if authorityErrors then return failure(authorityErrors) end
end
local target, targetErrors = buildTarget(state, staticData)
if targetErrors then return failure(targetErrors) end
local published, publishErrors = callModule(
"dataBridge",
"_publishCanonical",
VIEW_NAME,
target.view,
permitCanonicalOperation
)
if publishErrors then return failure(publishErrors) end
if type(published.encoded) ~= "string" or published.encoded == "" then
return failure({
makeError("missing_published_view", "$.runtime.dataBridge.encoded", "게시 결과에 gameSetupView 문자열이 없습니다."),
})
end
local shellErrors = ensureUiShell()
if shellErrors then return failure(shellErrors) end
-- getLoreBooks는 로어 내용을 반환하기 전에 CBS를 평가하므로
-- gameSetupView를 먼저 쓴 뒤 setup HTML을 로드한다.
local ui, loadUiErrors = loadSetupUi(state.phase)
if loadUiErrors then return failure(loadUiErrors) end
local uiErrors = writeChatVarStored(UI_NAME, ui, "$.chatVar.ui")
if uiErrors then return failure(uiErrors) end
local readyErrors = writeChatVarStored(READY_NAME, "ready", "$.chatVar.gameSetupReady")
if readyErrors then return failure(readyErrors) end
-- start/choose는 risu-btn 호출 전용이다. RisuAI가 button trigger
-- 완료 직후 클릭된 message pointer를 한 번 remount하므로 여기서
-- 또 reload하면 같은 HTML/CBS를 두 번 파싱하게 된다.
local returnState, stateError = cloneJson(target.state, "$.result.state")
if stateError then return failure({ stateError }) end
local returnView, viewError = cloneJson(target.view, "$.result.view")
if viewError then return failure({ viewError }) end
return success({
action = actionName,
applied = applied,
stale = stale,
state = returnState,
view = returnView,
})
end
local function publishRunState(
runState,
setupState,
writeAuthority,
actionName,
applied,
stale,
staticData,
expectedAuthority,
expectMissing
)
local beginErrors = beginRunPublish(
writeAuthority,
expectedAuthority,
expectMissing
)
if beginErrors then return beginErrors end
if writeAuthority then
local authorityErrors = writeRunAuthorityVerified(
runState,
expectedAuthority,
expectMissing
)
if authorityErrors then return failure(authorityErrors) end
end
local target, targetErrors = buildRunTarget(runState, setupState, staticData)
if targetErrors then return failure(targetErrors) end
local published, publishErrors = callModule(
"dataBridge",
"_publishCanonical",
RUN_VIEW_NAME,
target.view,
permitCanonicalOperation
)
if publishErrors then return failure(publishErrors) end
if type(published.encoded) ~= "string" or published.encoded == "" then
return failure({
makeError(
"missing_published_view",
"$.runtime.dataBridge.encoded",
"게시 결과에 runProgressionView 문자열이 없습니다."
),
})
end
local shellErrors = ensureUiShell()
if shellErrors then return failure(shellErrors) end
local ui, loadUiErrors = loadRunUi()
if loadUiErrors then return failure(loadUiErrors) end
local uiErrors = writeChatVarStored(UI_NAME, ui, "$.chatVar.ui")
if uiErrors then return failure(uiErrors) end
local readyErrors = writeChatVarStored(READY_NAME, "ready", "$.chatVar.gameSetupReady")
if readyErrors then return failure(readyErrors) end
local returnState, stateError = cloneJson(target.state, "$.result.state")
if stateError then return failure({ stateError }) end
local returnView, viewError = cloneJson(target.view, "$.result.view")
if viewError then return failure({ viewError }) end
return success({
action = actionName,
applied = applied,
stale = stale,
state = returnState,
view = returnView,
})
end
local function validateTransitionResult(report, previousState, moduleAction)
if type(report) ~= "table"
or type(report.state) ~= "table"
or type(report.applied) ~= "boolean"
or type(report.stale) ~= "boolean" then
return nil, {
makeError(
"invalid_transition_result",
"$.runtime.gameSetup." .. tostring(moduleAction),
"설정 전이 결과에 state/applied/stale가 올바르게 포함되지 않았습니다."
),
}
end
if report.stale == true and report.applied ~= false then
return nil, {
makeError(
"invalid_stale_result",
"$.runtime.gameSetup." .. tostring(moduleAction),
"stale 설정 전이는 적용 상태일 수 없습니다."
),
}
end
if report.stale == true and not deepEqual(report.state, previousState) then
return nil, {
makeError(
"stale_state_changed",
"$.runtime.gameSetup." .. tostring(moduleAction) .. ".state",
"stale 설정 전이가 권위 상태를 변경했습니다."
),
}
end
return report, nil
end
local function validateRunTransitionResult(report, previousState, moduleAction)
if type(report) ~= "table"
or type(report.state) ~= "table"
or type(report.applied) ~= "boolean"
or type(report.stale) ~= "boolean" then
return nil, {
makeError(
"invalid_transition_result",
"$.runtime.runProgression." .. tostring(moduleAction),
"진행 전이 결과에 state/applied/stale가 올바르게 포함되지 않았습니다."
),
}
end
if report.stale == true and report.applied ~= false then
return nil, {
makeError(
"invalid_stale_result",
"$.runtime.runProgression." .. tostring(moduleAction),
"stale 진행 전이는 적용 상태일 수 없습니다."
),
}
end
if report.stale == true
and (type(previousState) ~= "table" or not deepEqual(report.state, previousState)) then
return nil, {
makeError(
"stale_state_changed",
"$.runtime.runProgression." .. tostring(moduleAction) .. ".state",
"stale 진행 전이가 권위 상태를 변경했습니다."
),
}
end
return report, nil
end
local function advanceDeckComplete(state, staticData)
if state.phase ~= "deckComplete" then
return state, false, nil
end
local advanced, advanceErrors = callModule(
"gameSetup",
"beginCharacterSelect",
state,
staticData
)
if advanceErrors then return nil, false, advanceErrors end
local checked, checkedErrors = validateTransitionResult(
advanced,
state,
"beginCharacterSelect"
)
if checkedErrors then return nil, false, checkedErrors end
if checked.applied ~= true
or checked.stale ~= false
or checked.state.phase ~= "characterSelect" then
return nil, false, {
makeError(
"invalid_character_select_transition",
"$.runtime.gameSetup.beginCharacterSelect",
"deckComplete는 적용된 characterSelect 상태로 정확히 한 번 전환되어야 합니다."
),
}
end
return checked.state, true, nil
end
local function handoffBattle(
state,
writeAuthority,
actionName,
applied,
stale,
staticData,
expectedAuthority,
expectMissing
)
local target, targetErrors = buildTarget(state, staticData)
if targetErrors then return failure(targetErrors) end
if target.state.phase ~= "battleReady" then
return failure({
makeError("setup_not_battle_ready", "$.state.phase", "battleReady 설정만 전투로 인계할 수 있습니다."),
})
end
local beginErrors = beginPublish(
writeAuthority,
expectedAuthority,
expectMissing
)
if beginErrors then return beginErrors end
if writeAuthority then
local authorityErrors = writeAuthorityVerified(
target.state,
expectedAuthority,
expectMissing
)
if authorityErrors then return failure(authorityErrors) end
end
-- 저장된 battleReady 복구에서 UI chatVar만 사라졌을 수도 있으므로
-- 전투 body를 게시하기 전에 shell을 항상 보장한다.
local shellErrors = ensureUiShell()
if shellErrors then return failure(shellErrors) end
local battle, battleErrors = callModule(
"battleController",
"_startFromCanonicalSetup",
target.state,
permitCanonicalOperation
)
if battleErrors then return failure(battleErrors) end
if type(battle.applied) ~= "boolean"
or type(battle.reused) ~= "boolean"
or type(battle.recovered) ~= "boolean"
or battle.applied == battle.reused
or battle.battleId ~= target.state.battleSpec.battleId
or battle.setupId ~= target.state.setupId
or not isSafeInteger(battle.turnNumber, 1)
or type(battle.view) ~= "table" then
return failure({
makeError(
"invalid_battle_handoff_result",
"$.runtime.battleController.startFromSetup",
"전투 인계 결과가 setup identity와 필수 결과 계약을 만족하지 않습니다."
),
})
end
local readyErrors = writeChatVarStored(READY_NAME, "ready", "$.chatVar.gameSetupReady")
if readyErrors then return failure(readyErrors) end
local returnState, stateError = cloneJson(target.state, "$.result.state")
if stateError then return failure({ stateError }) end
local returnView, viewError = cloneJson(target.view, "$.result.view")
if viewError then return failure({ viewError }) end
local returnBattle, battleError = cloneJson(battle, "$.result.battle")
if battleError then return failure({ battleError }) end
local returnBattleView, battleViewError = cloneJson(battle.view, "$.result.battleView")
if battleViewError then return failure({ battleViewError }) end
return success({
action = actionName,
applied = applied,
stale = stale,
state = returnState,
view = returnView,
battle = returnBattle,
battleView = returnBattleView,
})
end
local function handoffRunBattle(
runState,
setupState,
writeAuthority,
actionName,
applied,
stale,
staticData,
expectedAuthority,
expectMissing
)
local target, targetErrors = buildRunTarget(runState, setupState, staticData)
if targetErrors then return failure(targetErrors) end
if target.state.phase ~= "battleReady" then
return failure({
makeError(
"run_not_battle_ready",
"$.state.runAuthority.phase",
"다음 상대 선택까지 끝난 battleReady 진행 상태만 전투로 인계할 수 있습니다."
),
})
end
local beginErrors = beginRunPublish(
writeAuthority,
expectedAuthority,
expectMissing
)
if beginErrors then return beginErrors end
if writeAuthority then
local authorityErrors = writeRunAuthorityVerified(
target.state,
expectedAuthority,
expectMissing
)
if authorityErrors then return failure(authorityErrors) end
end
local shellErrors = ensureUiShell()
if shellErrors then return failure(shellErrors) end
local battle, battleErrors = callModule(
"battleController",
"_startFromCanonicalRun",
target.state,
target.setupState,
permitCanonicalOperation
)
if battleErrors then return failure(battleErrors) end
local battleSpec = target.state.battleSpec
if type(battleSpec) ~= "table"
or type(battle.applied) ~= "boolean"
or type(battle.reused) ~= "boolean"
or type(battle.recovered) ~= "boolean"
or battle.applied == battle.reused
or battle.battleId ~= battleSpec.battleId
or battle.setupId ~= target.setupState.setupId
or not isSafeInteger(battle.turnNumber, 1)
or type(battle.view) ~= "table" then
return failure({
makeError(
"invalid_battle_handoff_result",
"$.runtime.battleController.startFromRun",
"다음 전투 인계 결과가 진행 상태 identity와 필수 결과 계약을 만족하지 않습니다."
),
})
end
local readyErrors = writeChatVarStored(READY_NAME, "ready", "$.chatVar.gameSetupReady")
if readyErrors then return failure(readyErrors) end
local returnState, stateError = cloneJson(target.state, "$.result.state")
if stateError then return failure({ stateError }) end
local returnView, viewError = cloneJson(target.view, "$.result.view")
if viewError then return failure({ viewError }) end
local returnBattle, battleError = cloneJson(battle, "$.result.battle")
if battleError then return failure({ battleError }) end
local returnBattleView, battleViewError = cloneJson(battle.view, "$.result.battleView")
if battleViewError then return failure({ battleViewError }) end
return success({
action = actionName,
applied = applied,
stale = stale,
state = returnState,
view = returnView,
battle = returnBattle,
battleView = returnBattleView,
})
end
local function settleProgression(setupState, currentRun, summary, staticData, actionName)
local settled, settleErrors = callModule(
"runProgression",
"settle",
currentRun,
setupState,
summary,
staticData
)
if settleErrors then return failure(settleErrors) end
local checked, checkedErrors = validateRunTransitionResult(
settled,
currentRun,
"settle"
)
if checkedErrors then return failure(checkedErrors) end
return publishRunState(
checked.state,
setupState,
checked.applied == true,
actionName,
checked.applied,
checked.stale,
staticData,
currentRun,
currentRun == nil
)
end
local function inspectTerminalBattle()
local inspected, inspectErrors = callModule(
"battleController",
"getTerminalSummary"
)
if inspectErrors then return nil, inspectErrors end
if type(inspected.terminal) ~= "boolean" then
return nil, {
makeError(
"invalid_terminal_inspection",
"$.runtime.battleController.getTerminalSummary",
"전투 종료 점검 결과에 terminal 불리언이 없습니다."
),
}
end
if type(inspected.settlementAvailable) ~= "boolean" then
return nil, {
makeError(
"invalid_terminal_inspection",
"$.runtime.battleController.getTerminalSummary.settlementAvailable",
"전투 종료 점검 결과에 settlementAvailable 불리언이 없습니다."
),
}
end
if inspected.terminal == true
and inspected.settlementAvailable == true
and (type(inspected.summary) ~= "table" or getmetatable(inspected.summary) ~= nil) then
return nil, {
makeError(
"missing_terminal_summary",
"$.runtime.battleController.getTerminalSummary.summary",
"종료된 전투 점검 결과에 정산 요약이 없습니다."
),
}
end
if inspected.terminal == true
and (type(inspected.battleId) ~= "string"
or type(inspected.status) ~= "string") then
return nil, {
makeError(
"invalid_terminal_identity",
"$.runtime.battleController.getTerminalSummary",
"종료된 전투 점검 결과에 battleId와 status가 없습니다."
),
}
end
return inspected, nil
end
local function validateInvocation()
if action == "completeBattle" then
if argumentCount ~= 1 then
return nil, {
makeError(
"invalid_argument_count",
"$.arguments",
"completeBattle 작업에는 종료 요약 하나가 필요합니다."
),
}
end
if type(arguments[1]) ~= "table" or getmetatable(arguments[1]) ~= nil then
return nil, {
makeError(
"invalid_battle_summary",
"$.arguments[1]",
"종료 요약은 메타테이블 없는 객체여야 합니다."
),
}
end
local summaryCopy, summaryError = cloneJson(arguments[1], "$.arguments[1]")
if summaryError then return nil, { summaryError } end
return { summary = summaryCopy }, nil
end
if action == "start" then
if argumentCount ~= 0 then
return nil, {
makeError("unexpected_arguments", "$.arguments", "start 작업은 인자를 받지 않습니다."),
}
end
return {}, nil
end
if action == "choose" then
if argumentCount ~= 2 then
return nil, {
makeError("invalid_argument_count", "$.arguments", "choose 작업에는 cardId와 interactionToken이 필요합니다."),
}
end
local cardId = arguments[1]
local interactionToken = arguments[2]
if type(cardId) ~= "string" or type(interactionToken) ~= "string" then
return nil, {
makeError("invalid_choose_arguments", "$.arguments", "cardId와 interactionToken은 문자열이어야 합니다."),
}
end
return { cardId = cardId, interactionToken = interactionToken }, nil
end
if action == "chooseCharacter" then
if argumentCount ~= 2 then
return nil, {
makeError(
"invalid_argument_count",
"$.arguments",
"chooseCharacter 작업에는 characterId와 interactionToken이 필요합니다."
),
}
end
local characterId = arguments[1]
local interactionToken = arguments[2]
if type(characterId) ~= "string" or type(interactionToken) ~= "string" then
return nil, {
makeError(
"invalid_choose_character_arguments",
"$.arguments",
"characterId와 interactionToken은 문자열이어야 합니다."
),
}
end
return { characterId = characterId, interactionToken = interactionToken }, nil
end
return nil, {
makeError("unknown_action", "$.action", "지원하지 않는 게임 설정 컨트롤러 작업입니다: " .. tostring(action)),
}
end
local function execute()
local command, invocationErrors = validateInvocation()
if invocationErrors then return failure(invocationErrors) end
local staticData, staticErrors = loadStaticData()
if staticErrors then return failure(staticErrors) end
if action == "completeBattle" then
local setupState, setupErrors = readAuthority(true)
if setupErrors then return failure(setupErrors) end
local currentRun, runErrors = readRunAuthority(false)
if runErrors then return failure(runErrors) end
return settleProgression(
setupState,
currentRun,
command.summary,
staticData,
"completeBattle"
)
end
if action == "start" then
local currentRun, runReadErrors = readRunAuthority(false)
if runReadErrors then return failure(runReadErrors) end
if currentRun ~= nil then
local setupState, setupReadErrors = readAuthority(true)
if setupReadErrors then return failure(setupReadErrors) end
local validatedRun, validationErrors = callModule(
"runProgression",
"validate",
currentRun,
setupState,
staticData
)
if validationErrors then return failure(validationErrors) end
if type(validatedRun.state) ~= "table"
or not deepEqual(currentRun, validatedRun.state) then
return failure({
makeError(
"run_authority_validation_mismatch",
"$.state.runAuthority",
"저장된 진행 상태의 정규 검증 결과가 원본과 일치하지 않습니다."
),
})
end
currentRun = validatedRun.state
if currentRun.phase == "battleReady" then
local inspected, inspectErrors = inspectTerminalBattle()
if inspectErrors then return failure(inspectErrors) end
if inspected.terminal == true
and type(currentRun.battleSpec) == "table"
and inspected.battleId == currentRun.battleSpec.battleId then
if inspected.settlementAvailable ~= true then
return failure({
makeError(
"missing_terminal_settlement_receipt",
"$.state.battleRuntime",
"현재 종료 전투를 정산할 마지막 확정 턴 영수증이 없습니다."
),
})
end
return settleProgression(
setupState,
currentRun,
inspected.summary,
staticData,
"start"
)
end
return handoffRunBattle(
currentRun,
setupState,
false,
"start",
false,
false,
staticData,
currentRun,
false
)
end
return publishRunState(
currentRun,
setupState,
false,
"start",
false,
false,
staticData,
currentRun,
false
)
end
local stored, readErrors = readAuthority(false)
if readErrors then return failure(readErrors) end
local expectedAuthority = stored
local expectMissing = stored == nil
local state
local writeAuthority = false
local applied = false
local publishBegun = false
if stored ~= nil then
local validated, validationErrors = callModule("gameSetup", "validate", stored, staticData)
if validationErrors then return failure(validationErrors) end
if type(validated.state) ~= "table" or not deepEqual(stored, validated.state) then
return failure({
makeError("authority_validation_mismatch", "$.state.authority", "저장된 설정 상태의 정규 검증 결과가 원본과 일치하지 않습니다."),
})
end
state = validated.state
else
local beginErrors = beginPublish(true, expectedAuthority, expectMissing)
if beginErrors then return beginErrors end
publishBegun = true
local seed, seedErrors = generateSetupSeed()
if seedErrors then return failure(seedErrors) end
local setupId = "setup-" .. string.format("%.0f", seed)
local started, startErrors = callModule(
"gameSetup",
"start",
{ setupId = setupId, seed = seed },
staticData
)
if startErrors then return failure(startErrors) end
if type(started.state) ~= "table" then
return failure({
makeError("missing_started_state", "$.runtime.gameSetup.state", "새 게임 설정 상태가 반환되지 않았습니다."),
})
end
state = started.state
writeAuthority = true
applied = true
end
local advancedState, advanced, advanceErrors = advanceDeckComplete(state, staticData)
if advanceErrors then return failure(advanceErrors) end
state = advancedState
if advanced then
writeAuthority = true
applied = true
end
if state.phase == "battleReady" then
local inspected, inspectErrors = inspectTerminalBattle()
if inspectErrors then return failure(inspectErrors) end
if inspected.terminal == true
and type(state.battleSpec) == "table"
and inspected.battleId == state.battleSpec.battleId then
if inspected.settlementAvailable ~= true then
return failure({
makeError(
"missing_terminal_settlement_receipt",
"$.state.battleRuntime",
"현재 종료 전투를 정산할 마지막 확정 턴 영수증이 없습니다."
),
})
end
if writeAuthority then
local authorityErrors = writeAuthorityVerified(
state,
expectedAuthority,
expectMissing
)
if authorityErrors then return failure(authorityErrors) end
writeAuthority = false
end
return settleProgression(
state,
nil,
inspected.summary,
staticData,
"start"
)
end
return handoffBattle(
state,
writeAuthority,
"start",
applied,
false,
staticData,
expectedAuthority,
expectMissing
)
end
if not publishBegun then
local beginErrors = beginPublish(
writeAuthority,
expectedAuthority,
expectMissing
)
if beginErrors then return beginErrors end
end
return publishState(
state,
writeAuthority,
"start",
applied,
false,
staticData,
expectedAuthority,
expectMissing
)
end
local stored, readErrors = readAuthority(true)
if readErrors then return failure(readErrors) end
local currentRun, runReadErrors = readRunAuthority(false)
if runReadErrors then return failure(runReadErrors) end
if currentRun ~= nil then
local runAction = action == "chooseCharacter"
and "chooseCharacter"
or "claimReward"
local transitioned, transitionErrors = callModule(
"runProgression",
runAction,
currentRun,
stored,
command,
staticData
)
if transitionErrors then return failure(transitionErrors) end
local checked, checkedErrors = validateRunTransitionResult(
transitioned,
currentRun,
runAction
)
if checkedErrors then return failure(checkedErrors) end
local runState = checked.state
local writeRun = checked.applied == true
if runState.phase == "battleReady" then
return handoffRunBattle(
runState,
stored,
writeRun,
action,
checked.applied,
checked.stale,
staticData,
currentRun,
false
)
end
return publishRunState(
runState,
stored,
writeRun,
action,
checked.applied,
checked.stale,
staticData,
currentRun,
false
)
end
local moduleAction = action == "chooseCharacter" and "chooseCharacter" or "choose"
-- 저장소에서 읽은 authority를 gameSetup이 처음부터 재생 검증한 뒤
-- 현재 카드 또는 캐릭터 interaction token만 적용한다.
local transitioned, transitionErrors = callModule(
"gameSetup",
moduleAction,
stored,
command,
staticData
)
if transitionErrors then return failure(transitionErrors) end
local checked, checkedErrors = validateTransitionResult(
transitioned,
stored,
moduleAction
)
if checkedErrors then return failure(checkedErrors) end
local state = checked.state
local applied = checked.applied
local stale = checked.stale
local advancedState, advanced, advanceErrors = advanceDeckComplete(state, staticData)
if advanceErrors then return failure(advanceErrors) end
if advanced then
state = advancedState
applied = true
stale = false
end
local writeAuthority = applied == true
if state.phase == "battleReady" then
return handoffBattle(
state,
writeAuthority,
action,
applied,
stale,
staticData,
stored,
false
)
end
local beginErrors = beginPublish(writeAuthority, stored, false)
if beginErrors then return beginErrors end
return publishState(
state,
writeAuthority,
action,
applied,
stale,
staticData,
stored,
false
)
end
local callOk, result = pcall(execute)
if not callOk then
result = failure({
makeError("controller_error", "$", "게임 설정 컨트롤러 실행 중 오류가 발생했습니다: " .. tostring(result)),
})
elseif type(result) ~= "table" or (result.ok ~= true and result.ok ~= false) then
result = failure({
makeError("invalid_controller_result", "$", "게임 설정 컨트롤러가 올바른 결과를 반환하지 않았습니다."),
})
end
if result.ok ~= true then
local first = type(result.errors) == "table" and result.errors[1] or nil
local diagnosticErrors = {}
for index, item in ipairs(type(result.errors) == "table" and result.errors or {}) do
diagnosticErrors[index] = {
code = type(item) == "table" and tostring(item.code) or "invalid_error",
path = type(item) == "table" and tostring(item.path) or "$",
message = type(item) == "table" and tostring(item.message) or tostring(item),
}
end
emitDiagnostic(1, "request_failed", {
action = tostring(action),
errorCount = #diagnosticErrors,
errors = diagnosticErrors,
})
local message = "게임 설정을 처리하지 못했습니다."
if type(first) == "table" then
message = message
.. "\n[" .. tostring(first.code) .. "] "
.. tostring(first.message)
.. "\n경로: " .. tostring(first.path)
if #diagnosticErrors > 1 then
message = message .. "\n전체 오류 수: " .. tostring(#diagnosticErrors)
end
end
if type(alertError) == "function" then
pcall(alertError, triggerId, message)
end
end
return result
end)
#39gameSetupView.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local TOTAL_ROUNDS = 10
local OFFER_SIZE = 3
local CHARACTER_OFFER_SIZE = 3
local COPY_LIMIT = 2
local function addError(errors, code, path, message)
table.insert(errors, {
code = code,
path = path,
message = message,
})
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
errors = errors,
}
end
local function success(key, value)
local result = {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
}
if key ~= nil then
result[key] = value
end
return result
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isInteger(value, minimum, maximum)
return isFinite(value)
and value % 1 == 0
and (minimum == nil or value >= minimum)
and (maximum == nil or value <= maximum)
end
local function isAsciiId(value)
return type(value) == "string"
and string.match(value, "^[a-z][a-z0-9_]*$") ~= nil
end
local function objectPath(path, key)
if type(key) == "string" and string.match(key, "^[A-Za-z_][A-Za-z0-9_]*$") then
return path .. "." .. key
end
return path .. "[" .. string.format("%q", tostring(key)) .. "]"
end
local function inspectTable(value, path, errors)
if type(value) ~= "table" then
addError(errors, "expected_table", path, "테이블이어야 합니다.")
return nil
end
local numericCount = 0
local maximum = 0
local hasNumeric = false
local hasString = false
local stringKeys = {}
for key in pairs(value) do
if type(key) == "number" then
hasNumeric = true
numericCount = numericCount + 1
if not isInteger(key, 1) then
addError(errors, "invalid_array_index", path, "배열 인덱스는 1 이상의 정수여야 합니다.")
return nil
end
if key > maximum then
maximum = key
end
elseif type(key) == "string" then
hasString = true
table.insert(stringKeys, key)
else
addError(errors, "invalid_object_key", path, "객체 키는 문자열이어야 합니다.")
return nil
end
end
if hasNumeric and hasString then
addError(errors, "mixed_table", path, "숫자 인덱스와 문자열 키를 함께 사용할 수 없습니다.")
return nil
end
if hasNumeric then
if numericCount ~= maximum then
addError(errors, "sparse_array", path, "배열 인덱스는 1부터 빈틈없이 이어져야 합니다.")
return nil
end
return "array", maximum
end
if hasString then
table.sort(stringKeys)
return "object", stringKeys
end
-- Lua의 빈 테이블은 JSON 배열로 취급한다. View에서 빈 객체는 없다.
return "array", 0
end
local function validateJsonSafe(value, path, errors, active)
local valueType = type(value)
if valueType == "string" or valueType == "boolean" then
return
end
if valueType == "number" then
if not isFinite(value) then
addError(errors, "non_finite_number", path, "NaN과 무한대는 View에 넣을 수 없습니다.")
end
return
end
if valueType ~= "table" then
addError(errors, "unsupported_type", path, "View에 넣을 수 없는 자료형입니다: " .. valueType)
return
end
if getmetatable(value) ~= nil then
addError(errors, "metatable_not_allowed", path, "View에는 메타테이블을 사용할 수 없습니다.")
return
end
active = active or {}
if active[value] then
addError(errors, "circular_reference", path, "순환 참조가 있는 View는 허용하지 않습니다.")
return
end
active[value] = true
local kind, shape = inspectTable(value, path, errors)
if kind == "array" then
for index = 1, shape do
validateJsonSafe(value[index], path .. "[" .. index .. "]", errors, active)
end
elseif kind == "object" then
for _, key in ipairs(shape) do
validateJsonSafe(value[key], objectPath(path, key), errors, active)
end
end
active[value] = nil
end
local function checkAllowedKeys(value, allowed, path, errors)
if type(value) ~= "table" then
return
end
for key in pairs(value) do
if type(key) ~= "string" or not allowed[key] then
addError(errors, "unknown_field", objectPath(path, key), "gameSetupView에 허용되지 않은 필드입니다.")
end
end
end
local function getArrayLength(value, path, errors)
local kind, length = inspectTable(value, path, errors)
if kind ~= "array" then
if kind ~= nil then
addError(errors, "expected_array", path, "연속 배열이어야 합니다.")
end
return nil
end
return length
end
local function normalizeStaticData(staticData)
if type(staticData) == "table" and type(staticData.data) == "table" then
return staticData.data
end
return staticData
end
local function appendNestedErrors(target, prefix, report)
if type(report) ~= "table" or type(report.errors) ~= "table" then
addError(target, "validation_failed", prefix, "하위 검증 결과를 읽을 수 없습니다.")
return
end
for _, item in ipairs(report.errors) do
local suffix = tostring(type(item) == "table" and item.path or "$.")
if string.sub(suffix, 1, 1) == "$" then
suffix = string.sub(suffix, 2)
end
addError(
target,
tostring(type(item) == "table" and item.code or "validation_failed"),
prefix .. suffix,
tostring(type(item) == "table" and item.message or "하위 검증에 실패했습니다.")
)
end
if #report.errors == 0 then
addError(target, "validation_failed", prefix, "하위 검증이 실패했지만 오류 내용이 없습니다.")
end
end
local function callRuntime(moduleName, moduleAction, ...)
if type(runScript) ~= "function" then
return nil, {
code = "runtime_unavailable",
path = "$.runtime." .. moduleName,
message = "스크립트 실행기를 찾을 수 없습니다.",
}
end
local ok, report = pcall(runScript, triggerId, moduleName, moduleAction, ...)
if not ok then
return nil, {
code = "runtime_call_error",
path = "$.runtime." .. moduleName,
message = moduleName .. "." .. moduleAction .. " 실행에 실패했습니다: " .. tostring(report),
}
end
if type(report) ~= "table" then
return nil, {
code = "invalid_runtime_result",
path = "$.runtime." .. moduleName,
message = "하위 모듈 결과가 테이블이 아닙니다.",
}
end
return report, nil
end
local function buildOfferCard(slot, cardId, ownedCopies, data, errors)
local path = "$.offer.cards[" .. slot .. "]"
local card = data.cards[cardId]
if type(card) ~= "table" or card.owner ~= "player" then
addError(errors, "missing_player_card", path .. ".cardId", "제안된 플레이어 카드를 찾을 수 없습니다: " .. tostring(cardId))
return nil
end
if card.id ~= cardId then
addError(errors, "card_identity_mismatch", path .. ".cardId", "제안 카드의 DB 키와 내부 ID가 일치하지 않습니다.")
return nil
end
local presentation, presentationError = callRuntime(
"viewBuilder",
"buildCardPresentation",
card,
data.registry,
path
)
if presentationError then
table.insert(errors, presentationError)
return nil
end
if presentation.ok ~= true or type(presentation.card) ~= "table" then
appendNestedErrors(errors, path, presentation)
return nil
end
if type(card.base) ~= "table" then
addError(errors, "missing_card_base", path, "카드 기본 수치를 찾을 수 없습니다.")
return nil
end
return {
slot = slot,
cardId = presentation.card.cardId,
name = presentation.card.name,
rarity = card.rarity,
descriptionSegments = presentation.card.descriptionSegments,
ruleLines = presentation.card.ruleLines,
cardType = presentation.card.cardType,
roles = presentation.card.roles,
mechanisms = presentation.card.mechanisms,
terms = presentation.card.terms,
baseStealthCost = card.base.stealthCost,
baseResistanceDamage = card.base.resistanceDamage,
ownedCopies = ownedCopies,
}
end
local function buildAppearanceSummary(profile, path, errors)
if type(profile) ~= "table" or type(profile.appearance) ~= "table" then
addError(errors, "missing_public_appearance", path, "공개 외형 정보를 찾을 수 없습니다.")
return nil
end
local appearance = profile.appearance
local parts = {}
local function appendText(value, suffix)
if type(value) == "string" and value ~= "" then
parts[#parts + 1] = value .. (suffix or "")
end
end
local function appendNumber(value, suffix)
if isFinite(value) then
parts[#parts + 1] = tostring(value) .. suffix
end
end
appendText(appearance.hairColor, " 머리")
appendText(appearance.eyeColor, " 눈")
appendText(appearance.skin)
appendNumber(appearance.height, "cm")
appendNumber(appearance.weight, "kg")
appendText(appearance.threeSize, "")
appendText(appearance.style)
if #parts == 0 then
addError(errors, "empty_public_appearance", path, "표시할 수 있는 공개 외형 요약이 없습니다.")
return nil
end
return table.concat(parts, " · ")
end
local function buildCharacterOfferItem(slot, characterId, data, errors)
local path = "$.characterOffer.characters[" .. slot .. "]"
local character = data.characters[characterId]
if type(character) ~= "table" or character.id ~= characterId then
addError(errors, "missing_character", path .. ".characterId", "제안된 캐릭터를 찾을 수 없습니다: " .. tostring(characterId))
return nil
end
if type(character.name) ~= "string" or character.name == "" then
addError(errors, "invalid_character_name", path .. ".name", "캐릭터 이름이 필요합니다.")
return nil
end
local profile = character.publicProfile
if type(profile) ~= "table" then
addError(errors, "missing_public_profile", path, "캐릭터 공개 프로필을 찾을 수 없습니다.")
return nil
end
if not isInteger(profile.age, 1) then
addError(errors, "invalid_character_age", path .. ".age", "공개 나이는 1 이상의 정수여야 합니다.")
end
if type(profile.occupation) ~= "string" or profile.occupation == "" then
addError(errors, "invalid_character_occupation", path .. ".occupation", "공개 직업이 필요합니다.")
end
local appearanceSummary = buildAppearanceSummary(profile, path .. ".appearanceSummary", errors)
local battle = character.battle
if type(battle) ~= "table" then
addError(errors, "missing_character_battle", path, "캐릭터 전투 정보를 찾을 수 없습니다.")
return nil
end
local mood = type(data.registry.moods) == "table" and data.registry.moods[battle.startingMood] or nil
if type(mood) ~= "table"
or mood.id ~= battle.startingMood
or type(mood.label) ~= "string"
or mood.label == "" then
addError(errors, "missing_starting_mood", path .. ".startingMood", "공개 시작 무드를 찾을 수 없습니다.")
return nil
end
local traits = {}
if type(battle.traitIds) ~= "table" then
addError(errors, "invalid_character_traits", path .. ".traits", "캐릭터 특징 목록을 찾을 수 없습니다.")
else
for index, traitId in ipairs(battle.traitIds) do
local trait = data.traits[traitId]
local traitPath = path .. ".traits[" .. index .. "]"
if type(trait) ~= "table"
or trait.id ~= traitId
or trait.owner ~= "character"
or trait.visibility ~= "public"
or type(trait.name) ~= "string"
or trait.name == ""
or type(trait.description) ~= "string"
or trait.description == "" then
addError(errors, "invalid_public_trait", traitPath, "공개 캐릭터 특징을 표시할 수 없습니다.")
else
traits[#traits + 1] = {
id = trait.id,
name = trait.name,
description = trait.description,
}
end
end
end
return {
slot = slot,
characterId = character.id,
name = character.name,
age = profile.age,
occupation = profile.occupation,
appearanceSummary = appearanceSummary,
startingResistance = battle.startingResistance,
turnLimit = battle.turnLimit,
startingMood = {
id = mood.id,
label = mood.label,
},
baseDrawCount = battle.baseDrawCount,
maxHandSize = battle.maxHandSize,
traits = traits,
}
end
local function validateTagView(value, path, errors, expectedTagKind)
if type(value) ~= "table" then
addError(errors, "invalid_tag_view", path, "태그 View가 테이블이 아닙니다.")
return
end
checkAllowedKeys(value, {
kind = true,
id = true,
label = true,
tagKind = true,
tooltip = true,
}, path, errors)
if value.kind ~= "tag" then
addError(errors, "invalid_tag_kind", path .. ".kind", "태그 조각의 kind는 tag여야 합니다.")
end
if not isAsciiId(value.id) then
addError(errors, "invalid_tag_id", path .. ".id", "태그 ID가 올바르지 않습니다.")
end
if type(value.label) ~= "string" or value.label == "" then
addError(errors, "invalid_tag_label", path .. ".label", "태그 표시명이 필요합니다.")
end
if value.tagKind ~= "type" and value.tagKind ~= "role" and value.tagKind ~= "mechanism" and value.tagKind ~= "term" then
addError(errors, "invalid_tag_type", path .. ".tagKind", "tagKind가 올바르지 않습니다.")
elseif expectedTagKind ~= nil and value.tagKind ~= expectedTagKind then
addError(errors, "tag_role_mismatch", path .. ".tagKind", "이 위치의 태그 종류가 올바르지 않습니다.")
end
if type(value.tooltip) ~= "string" or value.tooltip == "" then
addError(errors, "invalid_tag_tooltip", path .. ".tooltip", "태그 툴팁이 필요합니다.")
end
end
local function validateSegments(value, path, errors)
local length = getArrayLength(value, path, errors)
if length == nil then
return
end
for index = 1, length do
local segment = value[index]
local segmentPath = path .. "[" .. index .. "]"
if type(segment) ~= "table" then
addError(errors, "invalid_segment", segmentPath, "문장 조각이 테이블이 아닙니다.")
elseif segment.kind == "text" then
checkAllowedKeys(segment, { kind = true, value = true }, segmentPath, errors)
if type(segment.value) ~= "string" or segment.value == "" then
addError(errors, "invalid_text_segment", segmentPath .. ".value", "빈 텍스트 조각은 허용하지 않습니다.")
end
elseif segment.kind == "tag" then
validateTagView(segment, segmentPath, errors)
else
addError(errors, "invalid_segment_kind", segmentPath .. ".kind", "알 수 없는 문장 조각입니다.")
end
end
end
local function validateRuleLines(value, path, errors)
local length = getArrayLength(value, path, errors)
if length == nil then
return
end
if length < 1 then
addError(errors, "empty_rule_lines", path, "카드 규칙은 한 줄 이상이어야 합니다.")
end
for index = 1, length do
local line = value[index]
local linePath = path .. "[" .. index .. "]"
if type(line) ~= "table" then
addError(errors, "invalid_rule_line", linePath, "규칙 줄이 테이블이 아닙니다.")
else
checkAllowedKeys(line, { segments = true }, linePath, errors)
validateSegments(line.segments, linePath .. ".segments", errors)
end
end
end
local function validateMechanisms(value, path, errors)
local length = getArrayLength(value, path, errors)
if length == nil then
return
end
local seen = {}
for index = 1, length do
local tag = value[index]
validateTagView(tag, path .. "[" .. index .. "]", errors, "mechanism")
if type(tag) == "table" and type(tag.id) == "string" then
if seen[tag.id] then
addError(errors, "duplicate_mechanism", path .. "[" .. index .. "].id", "메커니즘 태그가 중복되었습니다.")
end
seen[tag.id] = true
end
end
end
local function validateTerms(value, path, errors)
local length = getArrayLength(value, path, errors)
if length == nil then return end
local seen = {}
for index = 1, length do
local tag = value[index]
validateTagView(tag, path .. "[" .. index .. "]", errors, "term")
if type(tag) == "table" and type(tag.id) == "string" then
if seen[tag.id] then
addError(errors, "duplicate_term", path .. "[" .. index .. "].id", "규칙 용어가 중복되었습니다.")
end
seen[tag.id] = true
end
end
end
local function validateOfferCard(card, path, expectedSlot, deckCopies, seenCards, errors)
if type(card) ~= "table" then
addError(errors, "invalid_offer_card", path, "제안 카드가 테이블이 아닙니다.")
return
end
checkAllowedKeys(card, {
slot = true,
cardId = true,
name = true,
rarity = true,
descriptionSegments = true,
ruleLines = true,
cardType = true,
roles = true,
mechanisms = true,
terms = true,
baseStealthCost = true,
baseResistanceDamage = true,
ownedCopies = true,
}, path, errors)
if card.slot ~= expectedSlot then
addError(errors, "invalid_offer_slot", path .. ".slot", "제안 카드 슬롯은 배열 순서와 같아야 합니다.")
end
if not isAsciiId(card.cardId) then
addError(errors, "invalid_card_id", path .. ".cardId", "카드 ID가 올바르지 않습니다.")
elseif seenCards[card.cardId] then
addError(errors, "duplicate_offer_card", path .. ".cardId", "같은 카드가 한 제안에 중복되었습니다.")
else
seenCards[card.cardId] = true
end
if type(card.name) ~= "string" or card.name == "" then
addError(errors, "invalid_card_name", path .. ".name", "카드 이름이 필요합니다.")
end
if card.rarity ~= "common" and card.rarity ~= "rare" then
addError(errors, "invalid_card_rarity", path .. ".rarity", "카드 희귀도는 common 또는 rare여야 합니다.")
end
validateSegments(card.descriptionSegments, path .. ".descriptionSegments", errors)
validateRuleLines(card.ruleLines, path .. ".ruleLines", errors)
validateTagView(card.cardType, path .. ".cardType", errors, "type")
local roleCount = getArrayLength(card.roles, path .. ".roles", errors)
if roleCount ~= nil then
if roleCount < 1 or roleCount > 2 then
addError(errors, "invalid_card_roles", path .. ".roles", "플레이어 카드는 역할 태그를 하나 또는 둘 가져야 합니다.")
end
for index = 1, roleCount do
validateTagView(card.roles[index], path .. ".roles[" .. index .. "]", errors, "role")
end
end
validateMechanisms(card.mechanisms, path .. ".mechanisms", errors)
validateTerms(card.terms, path .. ".terms", errors)
if not isFinite(card.baseStealthCost) or card.baseStealthCost < 0 then
addError(errors, "invalid_stealth_cost", path .. ".baseStealthCost", "기본 은폐 비용은 0 이상의 유한한 숫자여야 합니다.")
end
if not isFinite(card.baseResistanceDamage) or card.baseResistanceDamage < 0 then
addError(errors, "invalid_resistance_damage", path .. ".baseResistanceDamage", "기본 저항 피해는 0 이상의 유한한 숫자여야 합니다.")
end
if not isInteger(card.ownedCopies, 0, COPY_LIMIT - 1) then
addError(errors, "invalid_owned_copies", path .. ".ownedCopies", "제안 카드 보유 수는 0 또는 1이어야 합니다.")
elseif isAsciiId(card.cardId) and card.ownedCopies ~= (deckCopies[card.cardId] or 0) then
addError(errors, "owned_copies_mismatch", path .. ".ownedCopies", "제안 카드 보유 수가 덱 요약과 다릅니다.")
end
end
local function validateCharacterTrait(trait, path, seenTraits, errors)
if type(trait) ~= "table" then
addError(errors, "invalid_character_trait", path, "캐릭터 특징이 테이블이 아닙니다.")
return
end
checkAllowedKeys(trait, {
id = true,
name = true,
description = true,
}, path, errors)
if not isAsciiId(trait.id) then
addError(errors, "invalid_trait_id", path .. ".id", "특징 ID가 올바르지 않습니다.")
elseif seenTraits[trait.id] then
addError(errors, "duplicate_trait", path .. ".id", "같은 특징을 중복 표시할 수 없습니다.")
else
seenTraits[trait.id] = true
end
if type(trait.name) ~= "string" or trait.name == "" then
addError(errors, "invalid_trait_name", path .. ".name", "특징 이름이 필요합니다.")
end
if type(trait.description) ~= "string" or trait.description == "" then
addError(errors, "invalid_trait_description", path .. ".description", "특징 설명이 필요합니다.")
end
end
local function validateCharacterOfferItem(character, path, expectedSlot, seenCharacters, errors)
if type(character) ~= "table" then
addError(errors, "invalid_character_offer", path, "캐릭터 제안이 테이블이 아닙니다.")
return
end
checkAllowedKeys(character, {
slot = true,
characterId = true,
name = true,
age = true,
occupation = true,
appearanceSummary = true,
startingResistance = true,
turnLimit = true,
startingMood = true,
baseDrawCount = true,
maxHandSize = true,
traits = true,
}, path, errors)
if character.slot ~= expectedSlot then
addError(errors, "invalid_character_slot", path .. ".slot", "캐릭터 슬롯은 배열 순서와 같아야 합니다.")
end
if not isAsciiId(character.characterId) then
addError(errors, "invalid_character_id", path .. ".characterId", "캐릭터 ID가 올바르지 않습니다.")
elseif seenCharacters[character.characterId] then
addError(errors, "duplicate_offer_character", path .. ".characterId", "같은 캐릭터가 한 제안에 중복되었습니다.")
else
seenCharacters[character.characterId] = true
end
if type(character.name) ~= "string" or character.name == "" then
addError(errors, "invalid_character_name", path .. ".name", "캐릭터 이름이 필요합니다.")
end
if not isInteger(character.age, 1) then
addError(errors, "invalid_character_age", path .. ".age", "캐릭터 나이는 1 이상의 정수여야 합니다.")
end
if type(character.occupation) ~= "string" or character.occupation == "" then
addError(errors, "invalid_character_occupation", path .. ".occupation", "캐릭터 직업이 필요합니다.")
end
if type(character.appearanceSummary) ~= "string" or character.appearanceSummary == "" then
addError(errors, "invalid_appearance_summary", path .. ".appearanceSummary", "공개 외형 요약이 필요합니다.")
end
if not isFinite(character.startingResistance) or character.startingResistance <= 0 then
addError(errors, "invalid_starting_resistance", path .. ".startingResistance", "시작 저항은 양수의 유한한 숫자여야 합니다.")
end
if not isInteger(character.turnLimit, 7, 12) then
addError(errors, "invalid_turn_limit", path .. ".turnLimit", "제한 턴은 7 이상 12 이하의 정수여야 합니다.")
end
if type(character.startingMood) ~= "table" then
addError(errors, "invalid_starting_mood", path .. ".startingMood", "시작 무드가 테이블이 아닙니다.")
else
checkAllowedKeys(character.startingMood, { id = true, label = true }, path .. ".startingMood", errors)
if not isAsciiId(character.startingMood.id) then
addError(errors, "invalid_mood_id", path .. ".startingMood.id", "시작 무드 ID가 올바르지 않습니다.")
end
if type(character.startingMood.label) ~= "string" or character.startingMood.label == "" then
addError(errors, "invalid_mood_label", path .. ".startingMood.label", "시작 무드 표시명이 필요합니다.")
end
end
if not isInteger(character.baseDrawCount, 1) then
addError(errors, "invalid_base_draw_count", path .. ".baseDrawCount", "기본 드로우 수는 1 이상의 정수여야 합니다.")
end
if not isInteger(character.maxHandSize, 1) then
addError(errors, "invalid_max_hand_size", path .. ".maxHandSize", "최대 손패는 1 이상의 정수여야 합니다.")
elseif isInteger(character.baseDrawCount, 1) and character.baseDrawCount > character.maxHandSize then
addError(errors, "draw_exceeds_hand_limit", path .. ".baseDrawCount", "기본 드로우 수는 최대 손패보다 클 수 없습니다.")
end
local traitCount = getArrayLength(character.traits, path .. ".traits", errors)
if traitCount ~= nil then
local seenTraits = {}
for index = 1, traitCount do
validateCharacterTrait(
character.traits[index],
path .. ".traits[" .. index .. "]",
seenTraits,
errors
)
end
end
end
local function validateGameSetupView(view)
local errors = {}
validateJsonSafe(view, "$", errors)
if #errors > 0 then
if type(view) ~= "table" then
addError(errors, "invalid_view_root", "$", "gameSetupView 최상위 값은 테이블이어야 합니다.")
end
return failure(errors)
end
if type(view) ~= "table" or getmetatable(view) ~= nil then
if type(view) ~= "table" then
addError(errors, "invalid_view_root", "$", "gameSetupView 최상위 값은 테이블이어야 합니다.")
end
return failure(errors)
end
checkAllowedKeys(view, {
schemaVersion = true,
kind = true,
phase = true,
locked = true,
progress = true,
deck = true,
offer = true,
characterOffer = true,
selectedCharacter = true,
}, "$", errors)
if view.schemaVersion ~= SCHEMA_VERSION then
addError(errors, "unsupported_schema", "$.schemaVersion", "지원하지 않는 gameSetupView 스키마입니다.")
end
if view.kind ~= "gameSetupView" then
addError(errors, "unexpected_kind", "$.kind", "View 종류가 gameSetupView가 아닙니다.")
end
if view.phase ~= "deckDraft"
and view.phase ~= "deckComplete"
and view.phase ~= "characterSelect"
and view.phase ~= "battleReady" then
addError(errors, "invalid_phase", "$.phase", "게임 준비 단계가 올바르지 않습니다.")
end
if type(view.locked) ~= "boolean" then
addError(errors, "invalid_locked", "$.locked", "locked는 불리언이어야 합니다.")
elseif (view.phase == "deckComplete" or view.phase == "battleReady") ~= view.locked then
addError(errors, "locked_phase_mismatch", "$.locked", "완료 단계와 잠금 상태가 일치하지 않습니다.")
end
local selectedCount = nil
if type(view.progress) ~= "table" then
addError(errors, "invalid_progress", "$.progress", "progress가 테이블이 아닙니다.")
else
checkAllowedKeys(view.progress, {
selectedCount = true,
totalRounds = true,
currentRound = true,
remainingRounds = true,
}, "$.progress", errors)
selectedCount = view.progress.selectedCount
if not isInteger(selectedCount, 0, TOTAL_ROUNDS) then
addError(errors, "invalid_selected_count", "$.progress.selectedCount", "선택 수가 올바르지 않습니다.")
end
if view.progress.totalRounds ~= TOTAL_ROUNDS then
addError(errors, "invalid_total_rounds", "$.progress.totalRounds", "초기 덱 드래프트는 10회여야 합니다.")
end
local expectedRound = view.phase ~= "deckDraft"
and TOTAL_ROUNDS
or (isInteger(selectedCount, 0, TOTAL_ROUNDS - 1) and selectedCount + 1 or nil)
if expectedRound == nil or view.progress.currentRound ~= expectedRound then
addError(errors, "invalid_current_round", "$.progress.currentRound", "현재 드래프트 회차가 올바르지 않습니다.")
end
local expectedRemaining = isInteger(selectedCount, 0, TOTAL_ROUNDS)
and TOTAL_ROUNDS - selectedCount
or nil
if expectedRemaining == nil or view.progress.remainingRounds ~= expectedRemaining then
addError(errors, "invalid_remaining_rounds", "$.progress.remainingRounds", "남은 드래프트 횟수가 올바르지 않습니다.")
end
if view.phase == "deckDraft" and selectedCount == TOTAL_ROUNDS then
addError(errors, "draft_count_mismatch", "$.progress.selectedCount", "진행 중인 드래프트는 아직 10장을 선택할 수 없습니다.")
elseif view.phase ~= "deckDraft" and selectedCount ~= TOTAL_ROUNDS then
addError(errors, "complete_count_mismatch", "$.progress.selectedCount", "캐릭터 선택 이후에는 초기 덱이 정확히 10장이어야 합니다.")
end
end
local deckCopies = {}
local deckSum = 0
if type(view.deck) ~= "table" then
addError(errors, "invalid_deck", "$.deck", "deck이 테이블이 아닙니다.")
else
checkAllowedKeys(view.deck, { count = true, limit = true, items = true }, "$.deck", errors)
if not isInteger(view.deck.count, 0, TOTAL_ROUNDS) then
addError(errors, "invalid_deck_count", "$.deck.count", "덱 카드 수가 올바르지 않습니다.")
elseif isInteger(selectedCount, 0, TOTAL_ROUNDS) and view.deck.count ~= selectedCount then
addError(errors, "deck_count_mismatch", "$.deck.count", "덱 카드 수와 선택 수가 다릅니다.")
end
if view.deck.limit ~= TOTAL_ROUNDS then
addError(errors, "invalid_deck_limit", "$.deck.limit", "초기 덱 한도는 10장이어야 합니다.")
end
local itemCount = getArrayLength(view.deck.items, "$.deck.items", errors)
if itemCount then
for index = 1, itemCount do
local item = view.deck.items[index]
local path = "$.deck.items[" .. index .. "]"
if type(item) ~= "table" then
addError(errors, "invalid_deck_item", path, "덱 요약 항목이 테이블이 아닙니다.")
else
checkAllowedKeys(item, { cardId = true, name = true, copies = true }, path, errors)
if not isAsciiId(item.cardId) then
addError(errors, "invalid_card_id", path .. ".cardId", "카드 ID가 올바르지 않습니다.")
elseif deckCopies[item.cardId] ~= nil then
addError(errors, "duplicate_deck_item", path .. ".cardId", "덱 요약에 같은 카드가 중복되었습니다.")
else
deckCopies[item.cardId] = item.copies
end
if type(item.name) ~= "string" or item.name == "" then
addError(errors, "invalid_card_name", path .. ".name", "카드 이름이 필요합니다.")
end
if not isInteger(item.copies, 1, COPY_LIMIT) then
addError(errors, "invalid_card_copies", path .. ".copies", "같은 카드는 1장 또는 2장이어야 합니다.")
else
deckSum = deckSum + item.copies
end
end
end
end
if isInteger(view.deck.count, 0, TOTAL_ROUNDS) and deckSum ~= view.deck.count then
addError(errors, "deck_item_sum_mismatch", "$.deck.items", "덱 요약의 장수 합계가 덱 카드 수와 다릅니다.")
end
end
if view.phase == "deckDraft" then
if type(view.offer) ~= "table" then
addError(errors, "missing_offer", "$.offer", "진행 중인 드래프트에는 카드 제안이 필요합니다.")
else
checkAllowedKeys(view.offer, { interactionToken = true, cards = true }, "$.offer", errors)
if type(view.offer.interactionToken) ~= "string" or view.offer.interactionToken == "" then
addError(errors, "invalid_interaction_token", "$.offer.interactionToken", "상호작용 토큰이 필요합니다.")
end
local cardCount = getArrayLength(view.offer.cards, "$.offer.cards", errors)
if cardCount ~= nil then
if cardCount ~= OFFER_SIZE then
addError(errors, "invalid_offer_size", "$.offer.cards", "카드 제안은 정확히 3장이어야 합니다.")
end
local seenCards = {}
for index = 1, cardCount do
validateOfferCard(
view.offer.cards[index],
"$.offer.cards[" .. index .. "]",
index,
deckCopies,
seenCards,
errors
)
end
end
end
elseif view.offer ~= nil then
addError(errors, "unexpected_offer", "$.offer", "카드 드래프트 이후에는 카드 제안이 없어야 합니다.")
end
if view.phase == "characterSelect" then
if type(view.characterOffer) ~= "table" then
addError(errors, "missing_character_offer", "$.characterOffer", "캐릭터 선택 단계에는 세 명의 제안이 필요합니다.")
else
checkAllowedKeys(view.characterOffer, { interactionToken = true, characters = true }, "$.characterOffer", errors)
if type(view.characterOffer.interactionToken) ~= "string"
or view.characterOffer.interactionToken == "" then
addError(errors, "invalid_interaction_token", "$.characterOffer.interactionToken", "캐릭터 선택 상호작용 토큰이 필요합니다.")
end
local characterCount = getArrayLength(view.characterOffer.characters, "$.characterOffer.characters", errors)
if characterCount ~= nil then
if characterCount ~= CHARACTER_OFFER_SIZE then
addError(errors, "invalid_character_offer_size", "$.characterOffer.characters", "캐릭터 제안은 정확히 3명이어야 합니다.")
end
local seenCharacters = {}
for index = 1, characterCount do
validateCharacterOfferItem(
view.characterOffer.characters[index],
"$.characterOffer.characters[" .. index .. "]",
index,
seenCharacters,
errors
)
end
end
end
elseif view.characterOffer ~= nil then
addError(errors, "unexpected_character_offer", "$.characterOffer", "캐릭터 선택 단계 밖에는 캐릭터 제안이 없어야 합니다.")
end
if view.phase == "battleReady" then
if type(view.selectedCharacter) ~= "table" then
addError(errors, "missing_selected_character", "$.selectedCharacter", "전투 준비 상태에는 선택한 캐릭터가 필요합니다.")
else
checkAllowedKeys(view.selectedCharacter, { characterId = true, name = true }, "$.selectedCharacter", errors)
if not isAsciiId(view.selectedCharacter.characterId) then
addError(errors, "invalid_character_id", "$.selectedCharacter.characterId", "선택한 캐릭터 ID가 올바르지 않습니다.")
end
if type(view.selectedCharacter.name) ~= "string" or view.selectedCharacter.name == "" then
addError(errors, "invalid_character_name", "$.selectedCharacter.name", "선택한 캐릭터 이름이 필요합니다.")
end
end
elseif view.selectedCharacter ~= nil then
addError(errors, "unexpected_selected_character", "$.selectedCharacter", "전투 준비 전에는 선택 결과를 표시할 수 없습니다.")
end
if #errors > 0 then
return failure(errors)
end
return success("valid", true)
end
local function buildGameSetupView(state, staticData, canonicalInput)
local errors = {}
local canonicalState = state
if canonicalInput ~= true then
local authorityValidation, authorityCallError = callRuntime("gameSetup", "validate", state, staticData)
if authorityCallError then
table.insert(errors, authorityCallError)
return failure(errors)
end
if authorityValidation.ok ~= true then
appendNestedErrors(errors, "$.state", authorityValidation)
return failure(errors)
end
canonicalState = authorityValidation.state
if type(canonicalState) ~= "table" then
addError(errors, "invalid_authority_result", "$.state", "gameSetup.validate가 정규화된 권위 상태를 반환하지 않았습니다.")
return failure(errors)
end
elseif type(canonicalState) ~= "table" then
addError(errors, "invalid_canonical_authority", "$.state", "내부 canonical authority가 테이블이 아닙니다.")
return failure(errors)
end
local data = normalizeStaticData(staticData)
if type(data) ~= "table"
or type(data.cards) ~= "table"
or type(data.characters) ~= "table"
or type(data.traits) ~= "table"
or type(data.registry) ~= "table"
or type(data.registry.cardTypes) ~= "table"
or type(data.registry.roles) ~= "table"
or type(data.registry.mechanisms) ~= "table"
or type(data.registry.moods) ~= "table" then
addError(errors, "missing_static_data", "$.staticData", "gameSetupView 생성에는 검증된 카드·캐릭터·태그·무드 데이터가 필요합니다.")
return failure(errors)
end
local selectedCardIds = canonicalState.selectedCardIds
local copies = {}
local firstSeen = {}
for _, cardId in ipairs(selectedCardIds) do
if copies[cardId] == nil then
copies[cardId] = 0
table.insert(firstSeen, cardId)
end
copies[cardId] = copies[cardId] + 1
end
local deckItems = {}
for index, cardId in ipairs(firstSeen) do
local card = data.cards[cardId]
if type(card) ~= "table" or card.owner ~= "player" then
addError(errors, "missing_player_card", "$.state.selectedCardIds[" .. index .. "]", "선택한 플레이어 카드를 찾을 수 없습니다.")
elseif card.id ~= cardId then
addError(errors, "card_identity_mismatch", "$.state.selectedCardIds[" .. index .. "]", "선택 카드의 DB 키와 내부 ID가 일치하지 않습니다.")
else
table.insert(deckItems, {
cardId = card.id,
name = card.name,
copies = copies[cardId],
})
end
end
local selectedCount = #selectedCardIds
local view = {
schemaVersion = SCHEMA_VERSION,
kind = "gameSetupView",
phase = canonicalState.phase,
locked = canonicalState.phase == "deckComplete" or canonicalState.phase == "battleReady",
progress = {
selectedCount = selectedCount,
totalRounds = TOTAL_ROUNDS,
currentRound = canonicalState.phase ~= "deckDraft" and TOTAL_ROUNDS or selectedCount + 1,
remainingRounds = TOTAL_ROUNDS - selectedCount,
},
deck = {
count = selectedCount,
limit = TOTAL_ROUNDS,
items = deckItems,
},
}
if canonicalState.phase == "deckDraft" then
local offerCards = {}
for slot, cardId in ipairs(canonicalState.offer.cardIds) do
local cardView = buildOfferCard(slot, cardId, copies[cardId] or 0, data, errors)
if cardView then
table.insert(offerCards, cardView)
end
end
view.offer = {
interactionToken = canonicalState.offer.interactionToken,
cards = offerCards,
}
elseif canonicalState.phase == "characterSelect" then
local characters = {}
for slot, characterId in ipairs(canonicalState.characterOffer.characterIds) do
local characterView = buildCharacterOfferItem(slot, characterId, data, errors)
if characterView then
characters[#characters + 1] = characterView
end
end
view.characterOffer = {
interactionToken = canonicalState.characterOffer.interactionToken,
characters = characters,
}
elseif canonicalState.phase == "battleReady" then
local selectedCharacter = data.characters[canonicalState.selectedCharacterId]
if type(selectedCharacter) ~= "table"
or selectedCharacter.id ~= canonicalState.selectedCharacterId
or type(selectedCharacter.name) ~= "string"
or selectedCharacter.name == "" then
addError(errors, "missing_selected_character", "$.state.selectedCharacterId", "선택한 캐릭터 공개 정보를 찾을 수 없습니다.")
else
view.selectedCharacter = {
characterId = selectedCharacter.id,
name = selectedCharacter.name,
}
end
end
if #errors > 0 then
return failure(errors)
end
local validation = validateGameSetupView(view)
if validation.ok ~= true then
return validation
end
return success("view", view)
end
local arguments = { ... }
if action == "build" then
return buildGameSetupView(arguments[1], arguments[2])
elseif action == "_buildCanonical" then
-- 버튼 dispatcher는 문자열 인자만 전달할 수 있다. 이 경로는
-- 동일 Lua transaction에서 authority를 이미 검증한 controller가
-- 함수 capability를 전달한 경우에만 열린다.
local permit = arguments[3]
local permitOk = false
if type(permit) == "function" then
local callOk, permitted = pcall(permit, "gameSetupViewCanonicalV1")
permitOk = callOk and permitted == true
end
if not permitOk then
local errors = {}
addError(errors, "internal_action_denied", "$.action", "내부 canonical View 작업에 접근할 수 없습니다.")
return failure(errors)
end
return buildGameSetupView(arguments[1], arguments[2], true)
elseif action == "validate" then
return validateGameSetupView(arguments[1])
end
local errors = {}
addError(errors, "unknown_action", "$", "지원하지 않는 gameSetupView 작업입니다: " .. tostring(action))
return failure(errors)
end)
#40hostCompat.lua
Keys:
(function()
local function requireHostFunction(name, value)
if type(value) ~= "function" then
error(name .. " host function is unavailable")
end
return value
end
local function install()
HostCompat = {
readState = function(triggerId, name)
return requireHostFunction("getState", getState)(triggerId, name)
end,
writeState = function(triggerId, name, value)
local writer = type(setStateChanged) == "function" and setStateChanged or setState
return requireHostFunction("setStateChanged/setState", writer)(triggerId, name, value)
end,
writeChatVar = function(triggerId, name, value)
local writer = type(setChatVarChanged) == "function" and setChatVarChanged or setChatVar
return requireHostFunction("setChatVarChanged/setChatVar", writer)(triggerId, name, value)
end,
recentChats = function(triggerId, count)
count = math.max(0, math.floor(tonumber(count) or 0))
if type(getRecentChats) == "function" then
return getRecentChats(triggerId, count)
end
local chat = requireHostFunction("getFullChat", getFullChat)(triggerId)
local recent = {}
local first = math.max(1, #chat - count + 1)
for index = first, #chat do
recent[#recent + 1] = chat[index]
end
return recent
end,
chatData = function(triggerId, index)
if type(getChatData) == "function" then
return getChatData(triggerId, index)
end
local message = requireHostFunction("getChat", getChat)(triggerId, index)
return type(message) == "table" and message.data or ""
end,
}
return true
end
return function(_, action)
if action == "install" then
return install()
end
error("unsupported hostCompat action: " .. tostring(action))
end
end)()
#41hostFlow.lua
Keys:
(function()
local function controllerSucceeded(label, report)
if type(report) ~= "table" then
debug(1, label .. ": battleController가 결과를 반환하지 않았습니다.")
return false
end
if report.ok == true then
return true
end
local errors = type(report.errors) == "table" and report.errors or {}
if #errors == 0 then
debug(1, label .. ": battleController가 상세 오류 없이 실패했습니다.")
return false
end
for _, item in ipairs(errors) do
if type(item) == "table" then
debug(
1,
label
.. ": " .. tostring(item.code)
.. " at " .. tostring(item.path)
.. ": " .. tostring(item.message)
)
else
debug(1, label .. ": " .. tostring(item))
end
end
return false
end
local UI_BODY_VAR = "🔯🔯🔯"
local UI_SHELL_VAR = "helltrainUiShellV1"
local UI_POPUP_VAR = "helltrainUiPopupV1"
local UI_INTERACTION_VAR = "helltrainBattleInteractionV1"
local UI_TARGET_INDEX_VAR = "helltrainUiTargetIndexV1"
local UI_READY_VAR = "gameSetupReady"
local APPROACH_RETRY_VAR = "helltrainApproachRetryV1"
local RUN_PROGRESSION_AUTHORITY_KEY = "runProgressionV1.authority"
local APPROACH_REQUEST_ATTEMPTS = 1
local UI_CONTAINER_OPEN = [[
]]
local UI_CONTAINER_EMPTY = UI_CONTAINER_OPEN .. "
"
local UI_INTERACTION_MARKER = ""
-- 리롤이 직전 턴 경계에서 멈추도록 marker 메시지를 숨겨서 유지한다.
local TURN_SUBMIT_MARKER = "@@HELLTRAIN_TURN_SUBMIT_V1@@"
local TURN_SUBMIT_HIDDEN_MARKUP = [[
]]
local SETUP_START_MARKUP = [[
BOARDING PROTOCOL
지옥철에 탑승하시겠습니까?
게임을 시작하면 초기 상태를 만들고, 덱을 구성하기 위한 카드 드래프트를 엽니다.
게임 시작
]]
local APPROACH_PROCESSING_MARKUP = [[
처리중...
선택한 상대에게 접근하고 있습니다.
]]
local function readUiFragment(triggerId, name)
if type(getChatVar) ~= "function" then
return ""
end
local ok, value = pcall(getChatVar, triggerId, name)
return ok and type(value) == "string" and value ~= "null" and value or ""
end
local function parseUiTargetIndex(rawIndex)
local index = tonumber(rawIndex)
if index == nil or index % 1 ~= 0 or index < -1 then
return nil
end
return index
end
local function latestCharacterIndex(triggerId)
if type(getFullChat) ~= "function" then
error("getFullChat host function is unavailable")
end
local readOk, chat = pcall(getFullChat, triggerId)
if not readOk or type(chat) ~= "table" then
error("failed to read chat for UI target: " .. tostring(chat))
end
for index = #chat, 1, -1 do
if type(chat[index]) == "table" and chat[index].role == "char" then
return index - 1
end
end
return -1
end
local appendChatVerified
local function reloadGameUiAt(triggerId, index)
if type(reloadChat) == "function" then
local targetedOk, targetedError = pcall(reloadChat, triggerId, index)
if targetedOk then
return true
end
debug(2, "targeted UI reload failed: " .. tostring(targetedError))
end
if type(reloadDisplay) ~= "function" then
error("reloadChat/reloadDisplay host functions are unavailable")
end
reloadDisplay(triggerId)
return true
end
-- 초기 진입 전에는 first message(-1), 이후에는 UI가 붙은 캐릭터 메시지만 다시 그린다.
function refreshGameUi(triggerId)
local rawIndex = readUiFragment(triggerId, UI_TARGET_INDEX_VAR)
local index = parseUiTargetIndex(rawIndex)
if index == nil then
index = -1
if rawIndex ~= "" then
debug(2, "invalid UI target index; falling back to first message: " .. tostring(rawIndex))
end
end
return reloadGameUiAt(triggerId, index)
end
-- UI를 붙일 메시지를 바꾸고 이전/새 대상만 다시 그린다. nil이면 최신 char를 찾는다.
function syncGameUiTarget(triggerId, targetIndex)
if targetIndex == nil then
targetIndex = latestCharacterIndex(triggerId)
end
if type(targetIndex) ~= "number"
or targetIndex % 1 ~= 0
or targetIndex < -1 then
error("invalid UI target index: " .. tostring(targetIndex))
end
if type(HostCompat) ~= "table" or type(HostCompat.writeChatVar) ~= "function" then
error("chat variable write compatibility function is unavailable")
end
local rawPreviousIndex = readUiFragment(triggerId, UI_TARGET_INDEX_VAR)
local previousIndex = parseUiTargetIndex(rawPreviousIndex)
if previousIndex == nil then
previousIndex = -1
end
local encodedTargetIndex = tostring(targetIndex)
if rawPreviousIndex ~= encodedTargetIndex then
HostCompat.writeChatVar(triggerId, UI_TARGET_INDEX_VAR, encodedTargetIndex)
end
-- 새 대상을 먼저 저장해야 이전 메시지의 editDisplay가 UI를 제거한다.
if previousIndex ~= targetIndex and type(reloadChat) == "function" then
local retiredOk, retiredError = pcall(reloadChat, triggerId, previousIndex)
if not retiredOk then
debug(2, "previous UI target reload failed: " .. tostring(retiredError))
end
end
reloadGameUiAt(triggerId, targetIndex)
return targetIndex
end
local function writeUiFragment(triggerId, name, value)
if type(HostCompat) ~= "table" or type(HostCompat.writeChatVar) ~= "function" then
error("chat variable write compatibility function is unavailable")
end
HostCompat.writeChatVar(triggerId, name, value)
end
local function readApproachRetry(triggerId)
if type(getChatVar) ~= "function" then
error("getChatVar host function is unavailable")
end
local readOk, value = pcall(getChatVar, triggerId, APPROACH_RETRY_VAR)
if not readOk then
error("failed to read approach retry state: " .. tostring(value))
end
if value == nil or value == "" or value == "null" then
return nil, nil
end
if type(value) ~= "string" then
error("invalid approach retry state")
end
local separator = string.find(value, "|", 1, true)
local phase = separator and string.sub(value, 1, separator - 1) or nil
local characterId = separator and string.sub(value, separator + 1) or nil
if (phase ~= "pending" and phase ~= "generated")
or type(characterId) ~= "string"
or string.match(characterId, "^[a-z][a-z0-9_]*$") == nil then
error("invalid approach retry state")
end
return phase, characterId
end
local function writeApproachRetryVerified(triggerId, phase, characterId)
local value = ""
if phase ~= nil then
value = phase .. "|" .. characterId
end
writeUiFragment(triggerId, APPROACH_RETRY_VAR, value)
end
local function showApproachProcessing(triggerId)
writeUiFragment(triggerId, UI_BODY_VAR, APPROACH_PROCESSING_MARKUP)
writeUiFragment(triggerId, UI_POPUP_VAR, "")
writeUiFragment(triggerId, UI_READY_VAR, "ready")
refreshGameUi(triggerId)
end
local function selectedApproachCharacter(triggerId, report, characterId)
local selected = type(report) == "table"
and type(report.view) == "table"
and report.view.selectedCharacter
or nil
local name = type(selected) == "table" and selected.name or nil
local profile = selected
local staticOk, staticReport = pcall(
runScript,
triggerId,
"staticData",
"loadAll"
)
if staticOk
and type(staticReport) == "table"
and staticReport.ok == true
and type(staticReport.data) == "table"
and type(staticReport.data.characters) == "table"
and type(staticReport.data.characters[characterId]) == "table" then
profile = staticReport.data.characters[characterId]
if type(profile.name) == "string" and profile.name ~= "" then
name = profile.name
end
end
if type(name) ~= "string" or name == "" then
name = "선택한 캐릭터"
end
return name, profile
end
local function pastApproachEncounters(triggerId, report, characterId)
local runState = type(report) == "table"
and type(report.state) == "table"
and report.state.kind == "runProgressionV1"
and report.state
or nil
if runState == nil
and type(HostCompat) == "table"
and type(HostCompat.readState) == "function" then
local readOk, stored = pcall(HostCompat.readState, triggerId, RUN_PROGRESSION_AUTHORITY_KEY)
if readOk and type(stored) == "table" and stored.kind == "runProgressionV1" then
runState = stored
end
end
local encounters = {}
for sessionNumber, session in ipairs(
type(runState) == "table" and type(runState.sessions) == "table" and runState.sessions or {}
) do
if type(session) == "table" and session.characterId == characterId then
encounters[#encounters + 1] = {
sessionNumber = sessionNumber,
result = session.status,
reasonCode = session.reasonCode,
turnNumber = session.turnNumber,
turnLimit = session.turnLimit,
finalStealth = session.finalStealth,
finalResistance = session.finalResistance,
transit = session.transit,
}
end
end
return encounters
end
local function encodeApproachData(value, fallback)
if type(value) == "table" and type(json) == "table" and type(json.encode) == "function" then
local encodeOk, result = pcall(json.encode, value)
if encodeOk and type(result) == "string" then return result end
end
return fallback
end
local function buildApproachPrompt(characterName, profile, encounters)
local encodedProfile = encodeApproachData(profile, "{}")
local encodedEncounters = encodeApproachData(encounters, "[]")
return {
{
role = "system",
content = table.concat({
"당신은 성인용 한국어 다크 픽션 롤플레이의 장면 서술자다.",
"무한히 반복되는 지하철 안에서 플레이어가 선택한 성인 캐릭터에게 다가가는 순간을 2~4문단으로 묘사하라.",
"장면은 플레이어의 시야가 갑자기 검게 끊기는 순간으로 시작하라.",
"시야가 돌아오면 선택한 대상 캐릭터가 바로 눈앞에 나타나 있는 모습을 분명히 묘사하라.",
"암전과 대상의 등장은 이 접근 장면에서 한 번만 묘사하고, 이후 전투 턴에서 반복하지 마라.",
"경찰 연행이나 패배는 이 접근 장면에서 임의로 추론하거나 묘사하지 마라.",
"캐릭터의 성격과 배경을 지키고, 아직 카드·수치·전투 UI나 게임 규칙은 언급하지 마라.",
"플레이어가 입력하지 않은 추가 행동·대사·생각은 만들어내지 마라.",
"선택한 캐릭터의 자연스러운 행동과 반응, 객실의 분위기에 집중하라.",
"과거 조우 정보가 있으면 캐릭터는 플레이어를 분명히 알아보고, 이전 결과에 맞는 기억과 태도를 자연스럽게 드러내라. 정보가 없으면 초면으로 묘사하라.",
"과거 결과의 victory는 플레이어가 캐릭터의 저항을 무너뜨린 경우이고, defeat는 캐릭터가 플레이어를 물리친 경우다.",
"과거 조우 정보의 내부 ID와 수치는 직접 나열하지 말고 관계의 기억으로만 반영하라.",
"대상 캐릭터: " .. characterName,
"캐릭터 자료(JSON): " .. encodedProfile,
"과거 조우 정보(JSON): " .. encodedEncounters,
}, "\n"),
},
{
role = "user",
content = characterName .. "에게 접근한다.",
},
}
end
appendChatVerified = function(triggerId, role, content)
if type(getFullChat) ~= "function" or type(addChat) ~= "function" then
error("getFullChat/addChat host functions are unavailable")
end
local before = getFullChat(triggerId)
if type(before) ~= "table" then
error("failed to read chat before append")
end
addChat(triggerId, role, content)
local after = getFullChat(triggerId)
local appended = type(after) == "table" and after[#after] or nil
if type(after) ~= "table"
or #after ~= #before + 1
or type(appended) ~= "table"
or appended.role ~= role
or appended.data ~= content then
error("chat append was not persisted")
end
end
local function generateApproachScene(triggerId, report, characterId)
if type(LLM) ~= "function" then
return nil, "LLM 함수를 사용할 수 없습니다. Lua 스크립트의 low-level access를 활성화해야 합니다."
end
local characterName, profile = selectedApproachCharacter(triggerId, report, characterId)
local chat = getFullChat(triggerId)
local last = type(chat) == "table" and chat[#chat] or nil
if type(last) == "table"
and last.role == "char"
and type(last.data) == "string"
and last.data:match("%S") ~= nil
and last.time == 0 then
return last.data, nil
end
local encounters = pastApproachEncounters(triggerId, report, characterId)
local prompt = buildApproachPrompt(characterName, profile, encounters)
local lastError = "알 수 없는 LLM 오류"
for _ = 1, APPROACH_REQUEST_ATTEMPTS do
local requestOk, response = pcall(LLM, triggerId, prompt, false, { streaming = true })
if requestOk
and type(response) == "table"
and response.success == true
and type(response.result) == "string"
and response.result:match("%S") ~= nil then
appendChatVerified(triggerId, "char", response.result)
return response.result, nil
end
if requestOk and type(response) == "table" and response.result ~= nil then
lastError = tostring(response.result)
elseif not requestOk then
lastError = tostring(response)
end
end
return nil, lastError
end
local function removeApproachRetryFiller(triggerId)
if type(getFullChat) ~= "function" then
error("getFullChat host function is unavailable")
end
local chat = getFullChat(triggerId)
local last = type(chat) == "table" and chat[#chat] or nil
if type(last) ~= "table"
or last.role ~= "user"
or last.data ~= "says nothing" then
return
end
if type(removeChat) ~= "function" then
error("removeChat host function is unavailable")
end
removeChat(triggerId, #chat - 1)
local after = getFullChat(triggerId)
if type(after) ~= "table" or #after ~= #chat - 1 then
error("approach retry filler removal was not persisted")
end
end
local function finishApproachTransition(triggerId)
local report = runScript(triggerId, "init", "start")
if not controllerSucceeded("approach.init.start", report) then
return false
end
local targetOk, targetError = pcall(syncGameUiTarget, triggerId)
if not targetOk then
debug(1, "approach: Battle UI target 갱신 실패: " .. tostring(targetError))
return false
end
return true
end
local function resumeApproachTransition(triggerId, report, characterId, phase)
removeApproachRetryFiller(triggerId)
if phase == "pending" then
showApproachProcessing(triggerId)
local output, generationError = generateApproachScene(
triggerId,
report,
characterId
)
if output == nil then
return false, generationError
end
writeApproachRetryVerified(triggerId, "generated", characterId)
end
if not finishApproachTransition(triggerId) then
return false, "전투 화면으로 전환하지 못했습니다."
end
writeApproachRetryVerified(triggerId, nil, nil)
return true, nil
end
local function resumeApproachWithAlert(triggerId, report, characterId, phase)
local runOk, completed, detail = pcall(
resumeApproachTransition,
triggerId,
report,
characterId,
phase
)
if runOk and completed then
return true
end
detail = runOk and detail or completed
debug(1, "character approach: 생성 또는 전환 실패: " .. tostring(detail))
if type(alertError) == "function" then
pcall(
alertError,
triggerId,
"접근 장면을 생성하지 못했습니다. 전송 버튼을 눌러 다시 시도하세요.\n"
.. tostring(detail)
)
end
return false
end
-- target 메시지에만 최신 UI를 붙인다. 이전 target은 reloadChat될 때 원문으로 돌아간다.
local function handleEditDisplay(triggerId, data, meta)
if type(data) ~= "string" then
return data
end
if data == TURN_SUBMIT_MARKER then
return TURN_SUBMIT_HIDDEN_MARKUP
end
local index = type(meta) == "table" and meta.index or nil
if type(index) ~= "number" then
return data
end
local activeIndex = parseUiTargetIndex(readUiFragment(triggerId, UI_TARGET_INDEX_VAR)) or -1
if index ~= activeIndex then
return data
end
local rendered = SETUP_START_MARKUP
if readUiFragment(triggerId, UI_READY_VAR) == "ready" then
local body = readUiFragment(triggerId, UI_BODY_VAR)
local markerStart = string.find(body, UI_INTERACTION_MARKER, 1, true)
if markerStart ~= nil then
body = string.sub(body, 1, markerStart - 1)
.. readUiFragment(triggerId, UI_INTERACTION_VAR)
.. string.sub(body, markerStart + #UI_INTERACTION_MARKER)
end
rendered = readUiFragment(triggerId, UI_SHELL_VAR)
.. body
.. readUiFragment(triggerId, UI_POPUP_VAR)
end
if index == -1 then
local containerStart = string.find(data, UI_CONTAINER_EMPTY, 1, true)
if containerStart ~= nil then
local containerEnd = containerStart + #UI_CONTAINER_EMPTY - 1
return string.sub(data, 1, containerStart - 1)
.. UI_CONTAINER_OPEN .. rendered .. "</div>"
.. string.sub(data, containerEnd + 1)
end
return data
end
return data .. "\n" .. UI_CONTAINER_OPEN .. rendered .. "</div>"
local function isAllowedButtonRoute(script, arguments)
local actions = BUTTON_ACTIONS[script]
local action = arguments[1]
if type(actions) ~= "table" or actions[action] ~= true then
return false
end
if script == "init" then
return (action == "start" and #arguments == 1)
or (action == "choose" and #arguments == 3)
or (action == "chooseCharacter" and #arguments == 3)
elseif script == "battleController" then
return (action == "selectCardEffect" and #arguments == 4)
or (action == "armSubmission" and #arguments == 2)
or ((action == "clickCard"
or action == "registerCard"
or action == "cancelCard"
or action == "skipAftermath") and #arguments == 3)
elseif action == "back" or action == "close" then
return #arguments == 1
end
return #arguments >= 3 and #arguments <= 5
end
local function handleButtonClick(triggerId, data)
--risu-btn 값을 "스크립트|인자1|인자2" 형식으로 해석
local parts = splitByDelimiter(data, "|")
local script = table.remove(parts, 1)
if not script or script == "" then
debug(1, "button dispatch error: empty script.")
return
end
if not isAllowedButtonRoute(script, parts) then
debug(1, "button dispatch error: disallowed route " .. tostring(script) .. ".")
return
end
debug(3, "Button route: " .. tostring(script) .. "|" .. tostring(parts[1]))
local report = runScript(triggerId, script, table.unpack(parts))
if script == "init" and parts[1] == "start" then
if controllerSucceeded("onButtonClick.init.start", report) then
local targetOk, targetError = pcall(syncGameUiTarget, triggerId)
if not targetOk then
debug(1, "onButtonClick.init.start: UI target 갱신 실패: " .. tostring(targetError))
end
end
elseif script == "init"
and parts[1] == "chooseCharacter"
and type(report) == "table"
and report.ok == true
and report.applied == true then
local stateOk, stateError = pcall(
writeApproachRetryVerified,
triggerId,
"pending",
parts[2]
)
if not stateOk then
debug(1, "character approach: 재시도 상태 저장 실패: " .. tostring(stateError))
if type(alertError) == "function" then
pcall(alertError, triggerId, "접근 장면의 재시도 상태를 저장하지 못했습니다.")
end
return
end
resumeApproachWithAlert(
triggerId,
report,
parts[2],
"pending"
)
end
end
--정상 요청에 저장된 비공개 턴 사건과 사용자 장면 지시를 request에만 추가
local function handleEditRequest(triggerId, data)
local report = runScript(
triggerId,
"battleController",
"injectRequest",
data
)
if not controllerSucceeded("editRequest", report) then
return data
end
if type(report.promptArray) ~= "table" then
debug(1, "editRequest: 주입된 promptArray가 없습니다.")
return data
end
return report.promptArray
end
--수동 전송의 턴 준비·실패 복구·commit-only 복구
local function handleStart(triggerId)
local retryReadOk, retryPhase, retryCharacterId = pcall(
readApproachRetry,
triggerId
)
if not retryReadOk then
debug(1, "onStart: 접근 장면 재시도 상태 읽기 실패: " .. tostring(retryPhase))
if type(alertError) == "function" then
pcall(alertError, triggerId, "접근 장면의 재시도 상태가 올바르지 않습니다.")
end
return false
end
if retryPhase ~= nil then
resumeApproachWithAlert(
triggerId,
nil,
retryCharacterId,
retryPhase
)
return false
end
local report = runScript(
triggerId,
"battleController",
"prepareGeneration"
)
if not controllerSucceeded("onStart", report) then
return false
end
if report.commitRecovered == true or report.uiTargetRequired == true then
local targetOk, targetError = pcall(syncGameUiTarget, triggerId, report.uiTargetIndex)
if not targetOk then
debug(1, "onStart: 복구 UI target 갱신 실패: " .. tostring(targetError))
return false
end
end
-- 관측된 출력을 보존하고 commit만 복구한 경우 새 HTTP 요청은 취소한다.
return report.generationReady == true
end
--완성 응답을 관측한 뒤 턴을 한 번만 확정
local function handleOutput(triggerId)
return runScript(
triggerId,
"battleController",
"commitOutput"
)
end
return function(triggerId, action, ...)
if action == "editDisplay" then
return handleEditDisplay(triggerId, ...)
elseif action == "buttonClick" then
return handleButtonClick(triggerId, ...)
elseif action == "editRequest" then
return handleEditRequest(triggerId, ...)
elseif action == "start" then
return handleStart(triggerId, ...)
elseif action == "output" then
return handleOutput(triggerId, ...)
end
error("unsupported hostFlow action: " .. tostring(action))
end
(function(triggerId, action, targetScript, ...)
local POPUP_VAR = "helltrainUiPopupV1"
local POPUP_STATE = "popupState"
local targetArgs = {...}
local ALLOWED_POPUP_HTML = {
["캐릭터 리스트"] = true,
["도감"] = true,
["덱 확인"] = true,
["플레이 가이드"] = true,
["설정"] = true,
}
local function emptyState()
return {
current = nil,
history = {}
}
end
local function getPopupState()
local state = HostCompat.readState(triggerId, POPUP_STATE)
if type(state) ~= "table" then
return emptyState()
end
if type(state.history) ~= "table" then
state.history = {}
end
return state
end
local function setPopupState(state)
HostCompat.writeState(triggerId, POPUP_STATE, state)
local ok, encoded = pcall(json.encode, state)
if ok then
debug(2, "popup state saved: " .. encoded)
end
end
local function clearPopupHtml()
HostCompat.writeChatVar(triggerId, POPUP_VAR, "")
end
local function makeEntry(script, args)
return {
script = script,
args = args or {}
}
end
local function isAllowedEntry(entry)
if type(entry) ~= "table" or type(entry.script) ~= "string" then
return false
end
local args = type(entry.args) == "table" and entry.args or {}
if entry.script == "uiRender" then
return (args[1] == "append" or args[1] == "popup")
and ALLOWED_POPUP_HTML[args[2]] == true
and args[3] == nil
end
if entry.script == "캐릭터 프로필" then
return args[1] == "유지영" and args[2] == nil
end
return false
end
local function argsEqual(left, right)
left = left or {}
right = right or {}
if #left ~= #right then
return false
end
for index, value in ipairs(left) do
if value ~= right[index] then
return false
end
end
return true
end
local function entriesEqual(left, right)
return type(left) == "table"
and type(right) == "table"
and left.script == right.script
and argsEqual(left.args, right.args)
end
local function clearPopupState()
setPopupState(emptyState())
end
local function closePopup()
clearPopupHtml()
clearPopupState()
end
local function renderPopup(entry)
if not entry or not entry.script or entry.script == "" then
debug(1, "popupManage error: empty target script.")
return false
end
if not isAllowedEntry(entry) then
debug(1, "popupManage error: disallowed popup target.")
return false
end
clearPopupHtml()
runScript(triggerId, entry.script, table.unpack(entry.args or {}))
return true
end
local actions = {}
actions.root = function()
local state = getPopupState()
local nextEntry = makeEntry(targetScript, targetArgs)
if entriesEqual(state.current, nextEntry) then
closePopup()
return
end
local nextState = {
current = nextEntry,
history = {}
}
if not renderPopup(nextEntry) then
return
end
setPopupState(nextState)
end
actions.push = function()
local state = getPopupState()
local nextEntry = makeEntry(targetScript, targetArgs)
if state.current and state.current.script then
table.insert(state.history, state.current)
end
state.current = nextEntry
if not renderPopup(nextEntry) then
return
end
setPopupState(state)
end
actions.replace = function()
local state = getPopupState()
local nextEntry = makeEntry(targetScript, targetArgs)
state.current = nextEntry
if not renderPopup(nextEntry) then
return
end
setPopupState(state)
end
actions.back = function()
local state = getPopupState()
local previous = table.remove(state.history)
if not previous then
closePopup()
return
end
state.current = previous
if not renderPopup(previous) then
return
end
setPopupState(state)
end
actions.close = function()
closePopup()
end
local handler = actions[action]
if not handler then
debug(1, "popupManage error: unknown action " .. tostring(action))
return
end
handler()
end)
#44runProgression.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local KIND = "runProgressionV1"
local MIN_DECK_SIZE = 10
local MAX_DECK_SIZE = 20
local MAX_CARD_COPIES = 2
local REWARD_OFFER_SIZE = 3
local CHARACTER_OFFER_SIZE = 3
local MAX_SESSIONS = 999
local BATTLE_SEED_MAX = 2147483646
local MAX_SAFE_INTEGER = 9007199254740991
local TRANSIT_ALGORITHM = "tokyo_subway_segment_v1"
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
errors = errors,
}
end
local function success(state, applied, stale)
return {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
state = state,
applied = applied == true,
stale = stale == true,
}
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isSafeInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and math.abs(value) <= MAX_SAFE_INTEGER
and (minimum == nil or value >= minimum)
end
local function isRuntimeId(value)
return type(value) == "string"
and string.match(value, "^[A-Za-z0-9][A-Za-z0-9_-]*$") ~= nil
end
local function isAsciiId(value)
return type(value) == "string"
and string.match(value, "^[a-z][a-z0-9_]*$") ~= nil
end
local function objectPath(path, key)
if type(key) == "string"
and string.match(key, "^[A-Za-z_][A-Za-z0-9_]*$") ~= nil then
return path .. "." .. key
end
return path .. "[" .. string.format("%q", tostring(key)) .. "]"
end
local function appendError(errors, code, path, message)
errors[#errors + 1] = makeError(code, path, message)
end
local function inspectJsonTable(value, path, errors)
if type(value) ~= "table" then
appendError(errors, "expected_table", path, "테이블이어야 합니다.")
return nil, nil
end
if getmetatable(value) ~= nil then
appendError(errors, "metatable_not_allowed", path, "JSON 값에는 메타테이블을 사용할 수 없습니다.")
return nil, nil
end
local numericCount = 0
local maximum = 0
local hasNumeric = false
local hasString = false
local stringKeys = {}
for key in pairs(value) do
if type(key) == "number" then
hasNumeric = true
numericCount = numericCount + 1
if not isSafeInteger(key, 1) then
appendError(errors, "invalid_array_index", path, "배열 인덱스는 1 이상의 연속 정수여야 합니다.")
return nil, nil
end
if key > maximum then
maximum = key
end
elseif type(key) == "string" then
hasString = true
stringKeys[#stringKeys + 1] = key
else
appendError(errors, "invalid_object_key", path, "JSON 객체 키는 문자열이어야 합니다.")
return nil, nil
end
end
if hasNumeric and hasString then
appendError(errors, "mixed_table", path, "배열 인덱스와 객체 키를 함께 사용할 수 없습니다.")
return nil, nil
end
if hasNumeric then
if numericCount ~= maximum then
appendError(errors, "sparse_array", path, "배열 인덱스는 1부터 빈틈없이 이어져야 합니다.")
return nil, nil
end
return "array", maximum
end
table.sort(stringKeys)
return "object", stringKeys
end
local function validateJson(value, path, errors, active)
local valueType = type(value)
if valueType == "string" or valueType == "boolean" or valueType == "nil" then
return
end
if valueType == "number" then
if not isFinite(value) then
appendError(errors, "non_finite_number", path, "유한하지 않은 숫자는 상태에 저장할 수 없습니다.")
end
return
end
if valueType ~= "table" then
appendError(
errors,
"unsupported_type",
path,
"상태에 저장할 수 없는 자료형입니다: " .. valueType
)
return
end
if getmetatable(value) ~= nil then
appendError(errors, "metatable_not_allowed", path, "상태 값에는 메타테이블을 사용할 수 없습니다.")
return
end
active = active or {}
if active[value] then
appendError(errors, "circular_reference", path, "순환 참조가 있는 상태는 사용할 수 없습니다.")
return
end
active[value] = true
local kind, shape = inspectJsonTable(value, path, errors)
if kind == "array" then
for index = 1, shape do
validateJson(value[index], path .. "[" .. index .. "]", errors, active)
end
elseif kind == "object" then
for _, key in ipairs(shape) do
validateJson(value[key], objectPath(path, key), errors, active)
end
end
active[value] = nil
end
local function cloneJson(value, path, active)
local valueType = type(value)
if valueType == "nil" or valueType == "string" or valueType == "boolean" then
return value, nil
end
if valueType == "number" then
if not isFinite(value) then
return nil, makeError("non_finite_number", path, "유한하지 않은 숫자는 상태에 저장할 수 없습니다.")
end
return value, nil
end
if valueType ~= "table" then
return nil, makeError(
"unsupported_type",
path,
"상태에 저장할 수 없는 자료형입니다: " .. valueType
)
end
if getmetatable(value) ~= nil then
return nil, makeError("metatable_not_allowed", path, "상태 값에는 메타테이블을 사용할 수 없습니다.")
end
active = active or {}
if active[value] then
return nil, makeError("circular_reference", path, "순환 참조가 있는 상태는 사용할 수 없습니다.")
end
active[value] = true
local shapeErrors = {}
local kind, shape = inspectJsonTable(value, path, shapeErrors)
if #shapeErrors > 0 then
active[value] = nil
return nil, shapeErrors[1]
end
local copy = {}
if kind == "array" then
for index = 1, shape do
local itemCopy, itemError = cloneJson(value[index], path .. "[" .. index .. "]", active)
if itemError then
active[value] = nil
return nil, itemError
end
copy[index] = itemCopy
end
else
for _, key in ipairs(shape) do
local itemCopy, itemError = cloneJson(value[key], objectPath(path, key), active)
if itemError then
active[value] = nil
return nil, itemError
end
copy[key] = itemCopy
end
end
active[value] = nil
return copy, nil
end
local function deepEqual(left, right, seen)
if type(left) ~= type(right) then
return false
end
if type(left) ~= "table" then
return left == right
end
if getmetatable(left) ~= nil or getmetatable(right) ~= nil then
return false
end
seen = seen or {}
if seen[left] ~= nil then
return seen[left] == right
end
seen[left] = right
for key, value in pairs(left) do
if not deepEqual(value, right[key], seen) then
seen[left] = nil
return false
end
end
for key in pairs(right) do
if left[key] == nil then
seen[left] = nil
return false
end
end
seen[left] = nil
return true
end
local function checkAllowedKeys(value, allowed, path, errors)
if type(value) ~= "table" or getmetatable(value) ~= nil then
return
end
for key in pairs(value) do
if type(key) ~= "string" or allowed[key] ~= true then
appendError(errors, "unknown_field", objectPath(path, key), "허용되지 않은 필드입니다.")
end
end
end
local function denseArrayLength(value, path, errors)
local kind, shape = inspectJsonTable(value, path, errors)
if kind == "object" and type(shape) == "table" and #shape == 0 then
return 0
end
if kind ~= "array" then
if kind ~= nil then
appendError(errors, "expected_array", path, "1부터 시작하는 연속 배열이어야 합니다.")
end
return nil
end
return shape
end
local function copyArray(value)
local copy = {}
for index, item in ipairs(value or {}) do
copy[index] = item
end
return copy
end
local function contains(value, target)
for _, item in ipairs(value or {}) do
if item == target then
return true
end
end
return false
end
local function normalizeStaticData(staticInput)
if type(staticInput) == "table"
and getmetatable(staticInput) == nil
and type(rawget(staticInput, "data")) == "table" then
return rawget(staticInput, "data")
end
return staticInput
end
local function appendNestedErrors(target, moduleName, report)
local nested = type(report) == "table" and report.errors or nil
if type(nested) ~= "table" or #nested == 0 then
target[#target + 1] = makeError(
"module_failed_without_errors",
"$.runtime." .. moduleName,
moduleName .. "이 오류 상세 없이 실패했습니다."
)
return
end
for index, item in ipairs(nested) do
if type(item) == "table" then
target[#target + 1] = makeError(
type(item.code) == "string" and item.code or "nested_error",
type(item.path) == "string" and item.path or ("$.runtime." .. moduleName),
type(item.message) == "string"
and item.message
or (moduleName .. " 오류 " .. index .. "에 설명이 없습니다.")
)
else
target[#target + 1] = makeError(
"invalid_nested_error",
"$.runtime." .. moduleName .. ".errors[" .. index .. "]",
"하위 모듈 오류 항목이 객체가 아닙니다."
)
end
end
end
local function callModule(moduleName, moduleAction, ...)
if type(runScript) ~= "function" then
return nil, {
makeError(
"runtime_unavailable",
"$.runtime." .. moduleName,
moduleName .. "을 호출할 runScript 실행기가 없습니다."
),
}
end
local ok, report = pcall(runScript, triggerId, moduleName, moduleAction, ...)
if not ok then
return nil, {
makeError(
"module_call_error",
"$.runtime." .. moduleName,
moduleName .. "." .. moduleAction .. " 호출 중 오류가 발생했습니다: " .. tostring(report)
),
}
end
if type(report) ~= "table" or getmetatable(report) ~= nil then
return nil, {
makeError(
"invalid_module_result",
"$.runtime." .. moduleName,
moduleName .. "." .. moduleAction .. "이 일반 테이블 결과를 반환하지 않았습니다."
),
}
end
if report.schemaVersion ~= SCHEMA_VERSION
or type(report.errors) ~= "table"
or (report.ok ~= true and report.ok ~= false) then
return nil, {
makeError(
"invalid_module_envelope",
"$.runtime." .. moduleName,
moduleName .. "." .. moduleAction .. " 결과 envelope가 올바르지 않습니다."
),
}
end
if report.ok ~= true then
local errors = {}
appendNestedErrors(errors, moduleName, report)
return nil, errors
end
if #report.errors ~= 0 then
return nil, {
makeError(
"success_with_errors",
"$.runtime." .. moduleName .. ".errors",
"성공한 하위 모듈 결과에 오류가 포함되어 있습니다."
),
}
end
return report, nil
end
local function buildPools(staticInput)
local staticData = normalizeStaticData(staticInput)
local errors = {}
if type(staticData) ~= "table" or getmetatable(staticData) ~= nil then
appendError(errors, "invalid_static_data", "$.staticData", "정적 데이터는 일반 테이블이어야 합니다.")
return nil, nil, nil, errors
end
local cards = rawget(staticData, "cards")
local characters = rawget(staticData, "characters")
if type(cards) ~= "table" or getmetatable(cards) ~= nil then
appendError(errors, "invalid_card_database", "$.staticData.cards", "플레이어 카드 풀을 만들 cards 맵이 필요합니다.")
end
if type(characters) ~= "table" or getmetatable(characters) ~= nil then
appendError(errors, "invalid_character_database", "$.staticData.characters", "대상 후보를 만들 characters 맵이 필요합니다.")
end
if #errors > 0 then
return nil, nil, nil, errors
end
local playerPool = {}
for cardId, card in pairs(cards) do
local path = objectPath("$.staticData.cards", cardId)
if type(cardId) ~= "string" then
appendError(errors, "invalid_card_key", "$.staticData.cards", "카드 맵 키는 문자열이어야 합니다.")
elseif type(card) ~= "table" or getmetatable(card) ~= nil then
appendError(errors, "invalid_card_record", path, "카드 레코드는 일반 테이블이어야 합니다.")
elseif card.owner == "player" then
if not isAsciiId(cardId) or card.id ~= cardId then
appendError(errors, "invalid_player_card", path, "플레이어 카드 키와 내부 ID가 올바르지 않습니다.")
else
playerPool[#playerPool + 1] = cardId
end
end
end
table.sort(playerPool)
local characterPool = {}
for characterId, character in pairs(characters) do
local path = objectPath("$.staticData.characters", characterId)
if not isAsciiId(characterId) then
appendError(errors, "invalid_character_id", path, "캐릭터 키는 lower_snake_case ASCII ID여야 합니다.")
elseif type(character) ~= "table"
or getmetatable(character) ~= nil
or character.id ~= characterId then
appendError(errors, "invalid_character_record", path, "캐릭터 키와 내부 ID가 올바르지 않습니다.")
else
characterPool[#characterPool + 1] = characterId
end
end
table.sort(characterPool)
if #playerPool == 0 then
appendError(errors, "empty_player_card_pool", "$.staticData.cards", "카드 보상 후보가 될 플레이어 카드가 없습니다.")
end
if #characterPool < CHARACTER_OFFER_SIZE then
appendError(
errors,
"insufficient_character_pool",
"$.staticData.characters",
"다음 대상 선택에는 서로 다른 캐릭터가 최소 3명 필요합니다."
)
end
if #errors > 0 then
return nil, nil, nil, errors
end
return staticData, playerPool, characterPool, nil
end
local function validateSetup(setupInput, staticInput)
local setupCopy, setupCopyError = cloneJson(setupInput, "$.gameSetup")
if setupCopyError then
return nil, nil, nil, nil, { setupCopyError }
end
local staticData, playerPool, characterPool, poolErrors = buildPools(staticInput)
if poolErrors then
return nil, nil, nil, nil, poolErrors
end
local validated, validationErrors = callModule(
"gameSetup",
"validate",
setupCopy,
staticInput
)
if validationErrors then
return nil, nil, nil, nil, validationErrors
end
if type(validated.state) ~= "table" or getmetatable(validated.state) ~= nil then
return nil, nil, nil, nil, {
makeError(
"missing_validated_setup",
"$.runtime.gameSetup.state",
"gameSetup.validate가 canonical 설정 상태를 반환하지 않았습니다."
),
}
end
if not deepEqual(setupCopy, validated.state) then
return nil, nil, nil, nil, {
makeError(
"setup_validation_mismatch",
"$.gameSetup",
"원본 설정 상태가 seed와 선택 이력으로 재생한 canonical 상태와 다릅니다."
),
}
end
if validated.state.kind ~= "gameSetupV1" or validated.state.phase ~= "battleReady" then
return nil, nil, nil, nil, {
makeError(
"setup_not_battle_ready",
"$.gameSetup.phase",
"runProgression에는 원본 gameSetupV1 battleReady 상태가 필요합니다."
),
}
end
return validated.state, staticData, playerPool, characterPool, nil
end
local function callNextIntegers(rng, ranges)
local report, callErrors = callModule(
"deterministicRng",
"nextIntegers",
rng,
ranges
)
if callErrors then
return nil, nil, callErrors
end
local errors = {}
local valueCount = denseArrayLength(report.value, "$.runtime.deterministicRng.value", errors)
if valueCount ~= #ranges then
appendError(
errors,
"invalid_rng_batch_size",
"$.runtime.deterministicRng.value",
"결정적 RNG 결과 개수가 요청 범위 개수와 다릅니다."
)
elseif valueCount ~= nil then
for index = 1, valueCount do
local value = report.value[index]
local range = ranges[index]
if not isSafeInteger(value, range.minimum) or value > range.maximum then
appendError(
errors,
"invalid_rng_batch_value",
"$.runtime.deterministicRng.value[" .. index .. "]",
"결정적 RNG 결과가 요청 범위를 벗어났습니다."
)
end
end
end
if type(report.rng) ~= "table"
or getmetatable(report.rng) ~= nil
or not isSafeInteger(report.rng.seed, 0)
or not isSafeInteger(report.rng.cursor, 0)
or report.rng.seed ~= rng.seed
or (#ranges > 0 and report.rng.cursor <= rng.cursor)
or (#ranges == 0 and report.rng.cursor ~= rng.cursor) then
appendError(
errors,
"invalid_rng_result",
"$.runtime.deterministicRng.rng",
"결정적 RNG가 올바른 다음 상태를 반환하지 않았습니다."
)
end
if #errors > 0 then
return nil, nil, errors
end
return copyArray(report.value), {
seed = report.rng.seed,
cursor = report.rng.cursor,
}, nil
end
local function pickWithoutReplacement(rng, candidates, count)
local remaining = copyArray(candidates)
local ranges = {}
for pick = 1, count do
ranges[pick] = {
minimum = 1,
maximum = #remaining - pick + 1,
}
end
local selectedIndices, nextRng, rngErrors = callNextIntegers(rng, ranges)
if rngErrors then
return nil, nil, rngErrors
end
local selected = {}
for pick = 1, count do
local selectedIndex = selectedIndices[pick]
selected[pick] = remaining[selectedIndex]
table.remove(remaining, selectedIndex)
end
return selected, nextRng, nil
end
local function hashCanonical(prefix, canonical)
local hashA = 0
local hashB = 0
for index = 1, #canonical do
local byte = string.byte(canonical, index)
hashA = (hashA * 131 + byte) % 2147483647
hashB = (hashB * 137 + byte) % 2147483629
end
return prefix
.. ":" .. tostring(#canonical)
.. ":" .. tostring(hashA)
.. ":" .. tostring(hashB)
end
local function appendCanonicalArray(parts, label, array)
parts[#parts + 1] = "|" .. label .. "=" .. tostring(#array) .. ":"
for _, value in ipairs(array) do
parts[#parts + 1] = tostring(#value)
parts[#parts + 1] = ":"
parts[#parts + 1] = value
parts[#parts + 1] = ";"
end
end
local function buildRewardToken(setupId, sessionNumber, rng, settlement, deck, kind, cardIds)
local parts = {
"setupId=", tostring(#setupId), ":", setupId,
"|session=", tostring(sessionNumber),
"|cursor=", tostring(rng.cursor),
"|battleId=", tostring(#settlement.battleId), ":", settlement.battleId,
"|turnId=", tostring(#settlement.turnId), ":", settlement.turnId,
"|kind=", kind,
}
appendCanonicalArray(parts, "deck", deck)
appendCanonicalArray(parts, "offer", cardIds or {})
return hashCanonical("run-progression-reward-v1", table.concat(parts))
end
local function buildCharacterToken(setupId, sessionNumber, rng, settlement, deck, characterIds)
local parts = {
"setupId=", tostring(#setupId), ":", setupId,
"|session=", tostring(sessionNumber),
"|cursor=", tostring(rng.cursor),
"|battleId=", tostring(#settlement.battleId), ":", settlement.battleId,
"|turnId=", tostring(#settlement.turnId), ":", settlement.turnId,
}
appendCanonicalArray(parts, "deck", deck)
appendCanonicalArray(parts, "characters", characterIds)
return hashCanonical("run-progression-character-v1", table.concat(parts))
end
local function countDeck(deck, playerPool, path)
local errors = {}
local length = denseArrayLength(deck, path, errors)
if length ~= nil and (length < MIN_DECK_SIZE or length > MAX_DECK_SIZE) then
appendError(
errors,
"invalid_deck_size",
path,
"런 덱은 10장 이상 20장 이하여야 합니다."
)
end
local allowed = {}
for _, cardId in ipairs(playerPool) do
allowed[cardId] = true
end
local counts = {}
if length ~= nil then
for index = 1, length do
local cardId = deck[index]
if not isAsciiId(cardId) or allowed[cardId] ~= true then
appendError(
errors,
"unknown_player_card",
path .. "[" .. index .. "]",
"런 덱 카드가 현재 플레이어 카드 풀에 없습니다."
)
else
counts[cardId] = (counts[cardId] or 0) + 1
if counts[cardId] > MAX_CARD_COPIES then
appendError(
errors,
"card_copy_limit_exceeded",
path .. "[" .. index .. "]",
"런 덱에는 같은 카드를 2장까지만 넣을 수 있습니다."
)
end
end
end
end
if #errors > 0 then
return nil, nil, errors
end
return counts, length, nil
end
local function generateRewardOffer(
setupId,
sessionNumber,
rng,
settlement,
deck,
playerPool
)
local counts, deckLength, deckErrors = countDeck(deck, playerPool, "$.playerCardIds")
if deckErrors then
return nil, nil, deckErrors
end
local eligible = {}
if deckLength < MAX_DECK_SIZE then
for _, cardId in ipairs(playerPool) do
if (counts[cardId] or 0) < MAX_CARD_COPIES then
eligible[#eligible + 1] = cardId
end
end
end
if #eligible == 0 then
return {
kind = "none",
interactionToken = buildRewardToken(
setupId,
sessionNumber,
rng,
settlement,
deck,
"none",
{}
),
}, {
seed = rng.seed,
cursor = rng.cursor,
}, nil
end
local offerCount = math.min(REWARD_OFFER_SIZE, #eligible)
local cardIds, nextRng, offerErrors = pickWithoutReplacement(rng, eligible, offerCount)
if offerErrors then
return nil, nil, offerErrors
end
return {
kind = "card",
cardIds = cardIds,
interactionToken = buildRewardToken(
setupId,
sessionNumber,
nextRng,
settlement,
deck,
"card",
cardIds
),
}, nextRng, nil
end
local function generateCharacterOffer(
setupId,
sessionNumber,
rng,
settlement,
deck,
characterPool
)
local characterIds, nextRng, offerErrors = pickWithoutReplacement(
rng,
characterPool,
CHARACTER_OFFER_SIZE
)
if offerErrors then
return nil, nil, offerErrors
end
return {
characterIds = characterIds,
interactionToken = buildCharacterToken(
setupId,
sessionNumber,
nextRng,
settlement,
deck,
characterIds
),
}, nextRng, nil
end
local function generateBattleSeed(rng)
local values, nextRng, rngErrors = callNextIntegers(rng, {
{
minimum = 1,
maximum = BATTLE_SEED_MAX,
},
})
if rngErrors then
return nil, nil, rngErrors
end
return values[1], nextRng, nil
end
local function buildJourney(seed, staticInput, turnLimit)
local report, journeyErrors = callModule(
"subwayJourney",
"build",
seed,
staticInput,
turnLimit
)
if journeyErrors then
return nil, journeyErrors
end
local errors = {}
if not isSafeInteger(report.turnLimit, 1) or report.turnLimit ~= turnLimit then
appendError(errors, "invalid_turn_limit", "$.runtime.subwayJourney.turnLimit", "여정 제한 턴이 올바르지 않습니다.")
end
local transit = report.transit
if type(transit) ~= "table" or getmetatable(transit) ~= nil then
appendError(errors, "invalid_transit", "$.runtime.subwayJourney.transit", "여정 이동 구간이 일반 객체가 아닙니다.")
else
checkAllowedKeys(
transit,
{ algorithm = true, lineId = true, stationIds = true },
"$.runtime.subwayJourney.transit",
errors
)
if transit.algorithm ~= TRANSIT_ALGORITHM then
appendError(
errors,
"invalid_transit_algorithm",
"$.runtime.subwayJourney.transit.algorithm",
"지원하지 않는 여정 알고리즘입니다."
)
end
if not isAsciiId(transit.lineId) then
appendError(
errors,
"invalid_transit_line",
"$.runtime.subwayJourney.transit.lineId",
"여정 노선 ID가 올바르지 않습니다."
)
end
local stationCount = denseArrayLength(
transit.stationIds,
"$.runtime.subwayJourney.transit.stationIds",
errors
)
if stationCount ~= nil
and isSafeInteger(report.turnLimit, 1)
and stationCount ~= report.turnLimit + 1 then
appendError(
errors,
"transit_length_mismatch",
"$.runtime.subwayJourney.transit.stationIds",
"여정 역 개수는 제한 턴보다 하나 많아야 합니다."
)
elseif stationCount ~= nil then
for index = 1, stationCount do
if not isAsciiId(transit.stationIds[index]) then
appendError(
errors,
"invalid_station_id",
"$.runtime.subwayJourney.transit.stationIds[" .. index .. "]",
"여정 역 ID가 올바르지 않습니다."
)
end
end
end
end
if #errors > 0 then
return nil, errors
end
return {
turnLimit = report.turnLimit,
transit = {
algorithm = transit.algorithm,
lineId = transit.lineId,
stationIds = copyArray(transit.stationIds),
},
}, nil
end
local function buildBattleSpec(
setupId,
sessionNumber,
seed,
characterId,
deck,
staticInput
)
local staticData = normalizeStaticData(staticInput)
local turnLimit = staticData.characters[characterId].battle.turnLimit
local journey, journeyErrors = buildJourney(seed, staticInput, turnLimit)
if journeyErrors then
return nil, journeyErrors
end
return {
battleId = "battle-" .. setupId .. "-session-" .. string.format("%03d", sessionNumber),
seed = seed,
playerCardIds = copyArray(deck),
characterId = characterId,
perkIds = {},
turnLimit = journey.turnLimit,
lineId = journey.transit.lineId,
stationIds = copyArray(journey.transit.stationIds),
}, nil
end
local function buildInitialBattleSpec(setup, staticInput)
local staticData = normalizeStaticData(staticInput)
local turnLimit = staticData.characters[setup.selectedCharacterId].battle.turnLimit
local journey, journeyErrors = buildJourney(setup.battleSpec.seed, staticInput, turnLimit)
if journeyErrors then
return nil, journeyErrors
end
return {
battleId = setup.battleSpec.battleId,
seed = setup.battleSpec.seed,
playerCardIds = copyArray(setup.selectedCardIds),
characterId = setup.selectedCharacterId,
perkIds = {},
turnLimit = journey.turnLimit,
lineId = journey.transit.lineId,
stationIds = copyArray(journey.transit.stationIds),
}, nil
end
local OUTCOME_REASONS = {
card_checkpoint = true,
turn_end_checkpoint = true,
turn_limit = true,
}
local function normalizeSummary(summaryInput, staticData, path)
path = path or "$.summary"
local summary, cloneError = cloneJson(summaryInput, path)
if cloneError then
return nil, { cloneError }
end
local errors = {}
if type(summary) ~= "table" or getmetatable(summary) ~= nil then
appendError(errors, "invalid_summary", path, "전투 종료 summary는 일반 객체여야 합니다.")
return nil, errors
end
checkAllowedKeys(summary, {
battleId = true,
turnId = true,
characterId = true,
status = true,
reasonCode = true,
turnNumber = true,
turnLimit = true,
finalStealth = true,
finalResistance = true,
transit = true,
}, path, errors)
if not isRuntimeId(summary.battleId) then
appendError(errors, "invalid_battle_id", path .. ".battleId", "summary battleId가 올바르지 않습니다.")
end
if not isRuntimeId(summary.turnId) then
appendError(errors, "invalid_turn_id", path .. ".turnId", "summary turnId가 올바르지 않습니다.")
end
if not isAsciiId(summary.characterId)
or type(staticData.characters[summary.characterId]) ~= "table" then
appendError(errors, "unknown_character", path .. ".characterId", "summary 캐릭터를 정적 데이터에서 찾을 수 없습니다.")
end
if summary.status ~= "victory" and summary.status ~= "defeat" then
appendError(errors, "invalid_status", path .. ".status", "summary status는 victory 또는 defeat여야 합니다.")
end
if OUTCOME_REASONS[summary.reasonCode] ~= true then
appendError(errors, "invalid_reason_code", path .. ".reasonCode", "summary 종료 사유가 올바르지 않습니다.")
end
if not isSafeInteger(summary.turnNumber, 1) then
appendError(errors, "invalid_turn_number", path .. ".turnNumber", "summary 턴 번호는 1 이상의 정수여야 합니다.")
end
if not isSafeInteger(summary.turnLimit, 1)
or (isSafeInteger(summary.turnNumber, 1) and summary.turnNumber > summary.turnLimit) then
appendError(errors, "invalid_turn_limit", path .. ".turnLimit", "summary 제한 턴이 올바르지 않습니다.")
end
if not isFinite(summary.finalStealth) then
appendError(errors, "invalid_final_stealth", path .. ".finalStealth", "최종 은폐는 유한한 숫자여야 합니다.")
end
if not isFinite(summary.finalResistance) then
appendError(errors, "invalid_final_resistance", path .. ".finalResistance", "최종 저항은 유한한 숫자여야 합니다.")
end
local transit = summary.transit
if type(transit) ~= "table" or getmetatable(transit) ~= nil then
appendError(errors, "invalid_transit", path .. ".transit", "summary transit은 일반 객체여야 합니다.")
else
checkAllowedKeys(
transit,
{ algorithm = true, lineId = true, stationIds = true },
path .. ".transit",
errors
)
if transit.algorithm ~= TRANSIT_ALGORITHM then
appendError(
errors,
"invalid_transit_algorithm",
path .. ".transit.algorithm",
"summary transit 알고리즘이 올바르지 않습니다."
)
end
if not isAsciiId(transit.lineId)
or type(staticData.subwayLines) ~= "table"
or type(staticData.subwayLines[transit.lineId]) ~= "table" then
appendError(errors, "unknown_subway_line", path .. ".transit.lineId", "summary 지하철 노선을 찾을 수 없습니다.")
end
local stationCount = denseArrayLength(
transit.stationIds,
path .. ".transit.stationIds",
errors
)
if stationCount ~= nil
and isSafeInteger(summary.turnLimit, 1)
and stationCount ~= summary.turnLimit + 1 then
appendError(
errors,
"transit_length_mismatch",
path .. ".transit.stationIds",
"summary 역 개수는 제한 턴보다 하나 많아야 합니다."
)
elseif stationCount ~= nil then
for index = 1, stationCount do
if not isAsciiId(transit.stationIds[index]) then
appendError(
errors,
"invalid_station_id",
path .. ".transit.stationIds[" .. index .. "]",
"summary 역 ID가 올바르지 않습니다."
)
end
end
end
end
if isRuntimeId(summary.battleId)
and isSafeInteger(summary.turnNumber, 1)
and summary.turnId ~= string.format("%s-turn-%03d", summary.battleId, summary.turnNumber) then
appendError(
errors,
"turn_identity_mismatch",
path .. ".turnId",
"summary turnId가 battleId와 턴 번호에서 결정되는 ID와 다릅니다."
)
end
if isFinite(summary.finalResistance)
and isFinite(summary.finalStealth)
and (summary.status == "victory" or summary.status == "defeat") then
if summary.finalResistance <= 0 and summary.status ~= "victory" then
appendError(errors, "victory_priority", path .. ".status", "저항이 0 이하이면 승리를 우선해야 합니다.")
elseif summary.finalResistance > 0
and summary.finalStealth <= 0
and summary.status ~= "defeat" then
appendError(errors, "defeat_required", path .. ".status", "은폐가 0 이하이면 패배여야 합니다.")
elseif summary.status == "victory" and summary.finalResistance > 0 then
appendError(errors, "invalid_victory", path .. ".status", "저항이 남은 summary는 승리일 수 없습니다.")
elseif summary.status == "defeat"
and summary.finalStealth > 0
and summary.turnNumber < summary.turnLimit then
appendError(errors, "invalid_defeat", path .. ".status", "은폐가 남고 제한 턴 전이면 패배일 수 없습니다.")
end
end
if summary.reasonCode == "turn_limit"
and (summary.status ~= "defeat"
or summary.turnNumber ~= summary.turnLimit
or (isFinite(summary.finalResistance) and summary.finalResistance <= 0)
or (isFinite(summary.finalStealth) and summary.finalStealth <= 0)) then
appendError(
errors,
"invalid_turn_limit_outcome",
path .. ".reasonCode",
"turn_limit 패배는 마지막 턴에 저항과 은폐가 모두 남은 경우여야 합니다."
)
elseif (summary.reasonCode == "card_checkpoint"
or summary.reasonCode == "turn_end_checkpoint")
and isFinite(summary.finalResistance)
and isFinite(summary.finalStealth)
and summary.finalResistance > 0
and summary.finalStealth > 0 then
appendError(
errors,
"invalid_resource_outcome",
path .. ".reasonCode",
"자원 checkpoint 종료에는 저항 또는 은폐가 0 이하여야 합니다."
)
end
if #errors > 0 then
return nil, errors
end
return summary, nil
end
local function summaryMatchesSpec(summary, battleSpec, path)
path = path or "$.summary"
local errors = {}
if type(battleSpec) ~= "table" then
appendError(errors, "missing_battle_spec", "$.battleSpec", "summary와 대조할 현재 전투 사양이 없습니다.")
return errors
end
local comparisons = {
{ field = "battleId", expected = battleSpec.battleId },
{ field = "characterId", expected = battleSpec.characterId },
{ field = "turnLimit", expected = battleSpec.turnLimit },
}
for _, comparison in ipairs(comparisons) do
if summary[comparison.field] ~= comparison.expected then
appendError(
errors,
"settlement_battle_mismatch",
path .. "." .. comparison.field,
"종료 summary가 현재 battleReady 전투 사양과 다릅니다."
)
end
end
if type(summary.transit) == "table" then
if summary.transit.lineId ~= battleSpec.lineId then
appendError(
errors,
"settlement_transit_mismatch",
path .. ".transit.lineId",
"종료 summary 노선이 현재 전투 사양과 다릅니다."
)
end
if not deepEqual(summary.transit.stationIds, battleSpec.stationIds) then
appendError(
errors,
"settlement_transit_mismatch",
path .. ".transit.stationIds",
"종료 summary 이동 구간이 현재 전투 사양과 다릅니다."
)
end
end
return errors
end
local function validateRewardChoice(choiceInput, path)
local choice, cloneError = cloneJson(choiceInput, path)
if cloneError then
return nil, { cloneError }
end
local errors = {}
if type(choice) ~= "table" or getmetatable(choice) ~= nil then
appendError(errors, "invalid_reward_choice", path, "보상 선택은 일반 객체여야 합니다.")
return nil, errors
end
checkAllowedKeys(choice, { kind = true, cardId = true }, path, errors)
if choice.kind == "card" then
if not isAsciiId(choice.cardId) then
appendError(errors, "invalid_reward_card", path .. ".cardId", "카드 보상 선택에는 cardId가 필요합니다.")
end
elseif choice.kind == "none" then
if choice.cardId ~= nil then
appendError(errors, "unexpected_reward_card", path .. ".cardId", "none 보상 선택에는 cardId를 넣을 수 없습니다.")
end
else
appendError(errors, "invalid_reward_kind", path .. ".kind", "보상 선택 kind는 card 또는 none이어야 합니다.")
end
if #errors > 0 then
return nil, errors
end
return choice, nil
end
local function applyRewardChoice(offer, choice, deck, playerPool, path)
local counts, deckLength, deckErrors = countDeck(deck, playerPool, "$.playerCardIds")
if deckErrors then
return nil, deckErrors
end
if choice.kind == "none" then
return copyArray(deck), nil
end
if offer.kind == "none" then
return nil, {
makeError(
"reward_choice_mismatch",
path .. ".kind",
"보상 카드가 없을 때는 continue 선택만 사용할 수 있습니다."
),
}
end
if choice.kind ~= "card" or not contains(offer.cardIds, choice.cardId) then
return nil, {
makeError(
"reward_not_in_offer",
path,
"선택한 카드가 현재 보상 제안에 없습니다."
),
}
end
if deckLength >= MAX_DECK_SIZE or (counts[choice.cardId] or 0) >= MAX_CARD_COPIES then
return nil, {
makeError(
"reward_no_longer_eligible",
path .. ".cardId",
"선택한 카드를 현재 덱에 추가할 수 없습니다."
),
}
end
local nextDeck = copyArray(deck)
nextDeck[#nextDeck + 1] = choice.cardId
return nextDeck, nil
end
local SUMMARY_FIELDS = {
"battleId",
"turnId",
"characterId",
"status",
"reasonCode",
"turnNumber",
"turnLimit",
"finalStealth",
"finalResistance",
"transit",
}
local function copySettlementFromRecord(record, path)
local summaryInput = {}
for _, field in ipairs(SUMMARY_FIELDS) do
local value = record[field]
if field == "transit" then
local transitCopy, transitError = cloneJson(value, path .. ".transit")
if transitError then
return nil, transitError
end
summaryInput.transit = transitCopy
else
summaryInput[field] = value
end
end
return summaryInput, nil
end
local function makeSessionRecord(settlement)
local record = {}
for _, field in ipairs(SUMMARY_FIELDS) do
if field == "transit" then
record.transit = {
algorithm = settlement.transit.algorithm,
lineId = settlement.transit.lineId,
stationIds = copyArray(settlement.transit.stationIds),
}
else
record[field] = settlement[field]
end
end
return record
end
local function buildState(
setupId,
phase,
sessions,
rng,
deck,
stats,
phaseValue
)
local sessionCount = #sessions
local state = {
schemaVersion = SCHEMA_VERSION,
kind = KIND,
setupId = setupId,
phase = phase,
sessionNumber = phase == "battleReady" and sessionCount + 1 or sessionCount,
rng = {
seed = rng.seed,
cursor = rng.cursor,
},
playerCardIds = copyArray(deck),
perkIds = {},
stats = {
completed = stats.completed,
victories = stats.victories,
defeats = stats.defeats,
},
sessions = sessions,
lastSettlement = makeSessionRecord(sessions[sessionCount]),
}
if phase == "reward" then
state.rewardOffer = phaseValue
elseif phase == "characterSelect" then
state.characterOffer = phaseValue
elseif phase == "battleReady" then
state.battleSpec = phaseValue
end
return state
end
local function replaySessions(setup, staticData, staticInput, playerPool, characterPool, sessionInput)
local sessionShapeErrors = {}
local sessionCount = denseArrayLength(sessionInput, "$.state.sessions", sessionShapeErrors)
if sessionCount ~= nil and (sessionCount < 1 or sessionCount > MAX_SESSIONS) then
appendError(
sessionShapeErrors,
"invalid_session_count",
"$.state.sessions",
"runProgression에는 1개 이상 999개 이하의 정산 이력이 필요합니다."
)
end
if #sessionShapeErrors > 0 then
return nil, sessionShapeErrors
end
local initialSpec, initialSpecErrors = buildInitialBattleSpec(setup, staticInput)
if initialSpecErrors then
return nil, initialSpecErrors
end
local _, _, initialDeckErrors = countDeck(
setup.selectedCardIds,
playerPool,
"$.gameSetup.selectedCardIds"
)
if initialDeckErrors then
return nil, initialDeckErrors
end
local rng = {
seed = setup.rng.seed,
cursor = setup.rng.cursor,
}
local deck = copyArray(setup.selectedCardIds)
local currentSpec = initialSpec
local stats = {
completed = 0,
victories = 0,
defeats = 0,
}
local canonicalSessions = {}
for sessionIndex = 1, sessionCount do
local inputRecord = sessionInput[sessionIndex]
local recordPath = "$.state.sessions[" .. sessionIndex .. "]"
if type(inputRecord) ~= "table" or getmetatable(inputRecord) ~= nil then
return nil, {
makeError("invalid_session_record", recordPath, "정산 이력 항목은 일반 객체여야 합니다."),
}
end
local recordErrors = {}
checkAllowedKeys(
inputRecord,
{
battleId = true,
turnId = true,
characterId = true,
status = true,
reasonCode = true,
turnNumber = true,
turnLimit = true,
finalStealth = true,
finalResistance = true,
transit = true,
rewardChoice = true,
nextCharacterId = true,
},
recordPath,
recordErrors
)
if #recordErrors > 0 then
return nil, recordErrors
end
local settlementInput, settlementCopyError = copySettlementFromRecord(
inputRecord,
recordPath
)
if settlementCopyError then
return nil, { settlementCopyError }
end
local settlement, settlementErrors = normalizeSummary(
settlementInput,
staticData,
recordPath
)
if settlementErrors then
return nil, settlementErrors
end
local matchErrors = summaryMatchesSpec(
settlement,
currentSpec,
recordPath
)
if #matchErrors > 0 then
return nil, matchErrors
end
stats.completed = stats.completed + 1
if settlement.status == "victory" then
stats.victories = stats.victories + 1
else
stats.defeats = stats.defeats + 1
end
local canonicalRecord = makeSessionRecord(settlement)
canonicalSessions[sessionIndex] = canonicalRecord
if settlement.status == "victory" then
local rewardOffer, afterRewardRng, rewardErrors = generateRewardOffer(
setup.setupId,
sessionIndex,
rng,
settlement,
deck,
playerPool
)
if rewardErrors then
return nil, rewardErrors
end
rng = afterRewardRng
if inputRecord.rewardChoice == nil then
if sessionIndex ~= sessionCount then
return nil, {
makeError(
"incomplete_historical_session",
recordPath .. ".rewardChoice",
"마지막 항목이 아닌 승리 정산 이력에는 보상 선택이 필요합니다."
),
}
end
return buildState(
setup.setupId,
"reward",
canonicalSessions,
rng,
deck,
stats,
rewardOffer
), nil
end
local rewardChoice, choiceErrors = validateRewardChoice(
inputRecord.rewardChoice,
recordPath .. ".rewardChoice"
)
if choiceErrors then
return nil, choiceErrors
end
local nextDeck, applyErrors = applyRewardChoice(
rewardOffer,
rewardChoice,
deck,
playerPool,
recordPath .. ".rewardChoice"
)
if applyErrors then
return nil, applyErrors
end
deck = nextDeck
canonicalRecord.rewardChoice = rewardChoice
else
if inputRecord.rewardChoice ~= nil then
local rewardChoice, choiceErrors = validateRewardChoice(
inputRecord.rewardChoice,
recordPath .. ".rewardChoice"
)
if choiceErrors then
return nil, choiceErrors
end
if rewardChoice.kind ~= "none" then
return nil, {
makeError(
"reward_not_allowed_after_defeat",
recordPath .. ".rewardChoice",
"패배한 세션에서는 카드 보상을 획득할 수 없습니다."
),
}
end
end
canonicalRecord.rewardChoice = {
kind = "none",
}
end
local characterOffer, afterCharacterRng, characterErrors = generateCharacterOffer(
setup.setupId,
sessionIndex,
rng,
settlement,
deck,
characterPool
)
if characterErrors then
return nil, characterErrors
end
rng = afterCharacterRng
if inputRecord.nextCharacterId == nil then
if sessionIndex ~= sessionCount then
return nil, {
makeError(
"incomplete_historical_session",
recordPath .. ".nextCharacterId",
"마지막 항목이 아닌 정산 이력에는 다음 대상 선택이 필요합니다."
),
}
end
return buildState(
setup.setupId,
"characterSelect",
canonicalSessions,
rng,
deck,
stats,
characterOffer
), nil
end
if not isAsciiId(inputRecord.nextCharacterId)
or not contains(characterOffer.characterIds, inputRecord.nextCharacterId) then
return nil, {
makeError(
"character_not_in_replayed_offer",
recordPath .. ".nextCharacterId",
"다음 대상이 결정적으로 재생한 캐릭터 후보에 포함되지 않습니다."
),
}
end
canonicalRecord.nextCharacterId = inputRecord.nextCharacterId
if sessionIndex >= MAX_SESSIONS then
return nil, {
makeError(
"session_limit_reached",
recordPath .. ".nextCharacterId",
"세션 번호 999 뒤에는 새 전투를 만들 수 없습니다."
),
}
end
local battleSeed, afterSeedRng, seedErrors = generateBattleSeed(rng)
if seedErrors then
return nil, seedErrors
end
rng = afterSeedRng
local nextSpec, specErrors = buildBattleSpec(
setup.setupId,
sessionIndex + 1,
battleSeed,
inputRecord.nextCharacterId,
deck,
staticInput
)
if specErrors then
return nil, specErrors
end
currentSpec = nextSpec
if sessionIndex == sessionCount then
return buildState(
setup.setupId,
"battleReady",
canonicalSessions,
rng,
deck,
stats,
currentSpec
), nil
end
end
return nil, {
makeError("missing_progression_state", "$.state", "정산 이력에서 runProgression 상태를 재생하지 못했습니다."),
}
end
local function validateStateShape(state, setupId)
local errors = {}
validateJson(state, "$.state", errors, {})
if type(state) ~= "table" or getmetatable(state) ~= nil then
return errors
end
checkAllowedKeys(state, {
schemaVersion = true,
kind = true,
setupId = true,
phase = true,
sessionNumber = true,
rng = true,
playerCardIds = true,
perkIds = true,
stats = true,
sessions = true,
lastSettlement = true,
rewardOffer = true,
characterOffer = true,
battleSpec = true,
}, "$.state", errors)
if state.schemaVersion ~= SCHEMA_VERSION then
appendError(errors, "unsupported_schema_version", "$.state.schemaVersion", "지원하지 않는 runProgression 스키마입니다.")
end
if state.kind ~= KIND then
appendError(errors, "invalid_state_kind", "$.state.kind", "상태 kind는 runProgressionV1이어야 합니다.")
end
if state.setupId ~= setupId then
appendError(errors, "setup_identity_mismatch", "$.state.setupId", "상태가 원본 gameSetup에 속하지 않습니다.")
end
if state.phase ~= "reward"
and state.phase ~= "characterSelect"
and state.phase ~= "battleReady" then
appendError(errors, "invalid_phase", "$.state.phase", "지원하지 않는 runProgression phase입니다.")
end
if not isSafeInteger(state.sessionNumber, 1) or state.sessionNumber > MAX_SESSIONS then
appendError(errors, "invalid_session_number", "$.state.sessionNumber", "세션 번호는 1 이상 999 이하여야 합니다.")
end
if type(state.rng) ~= "table" or getmetatable(state.rng) ~= nil then
appendError(errors, "invalid_rng", "$.state.rng", "progression rng는 일반 객체여야 합니다.")
else
checkAllowedKeys(state.rng, { seed = true, cursor = true }, "$.state.rng", errors)
if not isSafeInteger(state.rng.seed, 0) then
appendError(errors, "invalid_rng_seed", "$.state.rng.seed", "progression RNG seed가 올바르지 않습니다.")
end
if not isSafeInteger(state.rng.cursor, 0) then
appendError(errors, "invalid_rng_cursor", "$.state.rng.cursor", "progression RNG cursor가 올바르지 않습니다.")
end
end
local perkCount = denseArrayLength(state.perkIds, "$.state.perkIds", errors)
if perkCount ~= nil and perkCount ~= 0 then
appendError(errors, "unsupported_perks", "$.state.perkIds", "현재 progression 버전의 perkIds는 빈 배열이어야 합니다.")
end
if type(state.stats) ~= "table" or getmetatable(state.stats) ~= nil then
appendError(errors, "invalid_stats", "$.state.stats", "누적 전적은 일반 객체여야 합니다.")
else
checkAllowedKeys(
state.stats,
{ completed = true, victories = true, defeats = true },
"$.state.stats",
errors
)
for _, field in ipairs({ "completed", "victories", "defeats" }) do
if not isSafeInteger(state.stats[field], 0) then
appendError(errors, "invalid_stat", "$.state.stats." .. field, "누적 전적은 0 이상의 정수여야 합니다.")
end
end
if isSafeInteger(state.stats.completed, 0)
and isSafeInteger(state.stats.victories, 0)
and isSafeInteger(state.stats.defeats, 0)
and state.stats.completed ~= state.stats.victories + state.stats.defeats then
appendError(errors, "stats_total_mismatch", "$.state.stats", "완료 세션 수가 승리와 패배 합계와 다릅니다.")
end
end
local sessionCount = denseArrayLength(state.sessions, "$.state.sessions", errors)
if sessionCount ~= nil and (sessionCount < 1 or sessionCount > MAX_SESSIONS) then
appendError(errors, "invalid_session_count", "$.state.sessions", "정산 이력 개수가 올바르지 않습니다.")
end
if state.phase == "reward" then
if state.rewardOffer == nil then
appendError(errors, "missing_reward_offer", "$.state.rewardOffer", "reward phase에는 보상 제안이 필요합니다.")
end
if state.characterOffer ~= nil or state.battleSpec ~= nil then
appendError(errors, "unexpected_phase_field", "$.state", "reward phase에는 다른 phase 자료를 넣을 수 없습니다.")
end
elseif state.phase == "characterSelect" then
if state.characterOffer == nil then
appendError(errors, "missing_character_offer", "$.state.characterOffer", "characterSelect phase에는 대상 후보가 필요합니다.")
end
if state.rewardOffer ~= nil or state.battleSpec ~= nil then
appendError(errors, "unexpected_phase_field", "$.state", "characterSelect phase에는 다른 phase 자료를 넣을 수 없습니다.")
end
elseif state.phase == "battleReady" then
if state.battleSpec == nil then
appendError(errors, "missing_battle_spec", "$.state.battleSpec", "battleReady phase에는 다음 전투 사양이 필요합니다.")
end
if state.rewardOffer ~= nil or state.characterOffer ~= nil then
appendError(errors, "unexpected_phase_field", "$.state", "battleReady phase에는 다른 phase 자료를 넣을 수 없습니다.")
end
end
return errors
end
local function validateAndReplay(
stateInput,
setup,
staticData,
staticInput,
playerPool,
characterPool
)
local state, cloneError = cloneJson(stateInput, "$.state")
if cloneError then
return nil, { cloneError }
end
local shapeErrors = validateStateShape(state, setup.setupId)
if #shapeErrors > 0 then
return nil, shapeErrors
end
local expected, replayErrors = replaySessions(
setup,
staticData,
staticInput,
playerPool,
characterPool,
state.sessions
)
if replayErrors then
return nil, replayErrors
end
if not deepEqual(state, expected) then
return nil, {
makeError(
"progression_state_replay_mismatch",
"$.state",
"저장된 runProgression 상태가 원본 setup과 선택 이력으로 재생한 canonical 상태와 다릅니다."
),
}
end
return expected, nil
end
local function validateClaimCommand(commandInput)
local command, cloneError = cloneJson(commandInput, "$.command")
if cloneError then
return nil, { cloneError }
end
local errors = {}
if type(command) ~= "table" or getmetatable(command) ~= nil then
appendError(errors, "invalid_command", "$.command", "보상 선택 명령은 일반 객체여야 합니다.")
return nil, errors
end
checkAllowedKeys(
command,
{ cardId = true, interactionToken = true },
"$.command",
errors
)
if not isAsciiId(command.cardId) then
appendError(
errors,
"invalid_command_card",
"$.command.cardId",
"보상 선택에는 플레이어 cardId 또는 continue가 필요합니다."
)
end
if type(command.interactionToken) ~= "string"
or string.match(
command.interactionToken,
"^run%-progression%-reward%-v1:%d+:%d+:%d+$"
) == nil then
appendError(errors, "invalid_command_token", "$.command.interactionToken", "보상 interactionToken 형식이 올바르지 않습니다.")
end
if #errors > 0 then
return nil, errors
end
return command, nil
end
local function validateCharacterCommand(commandInput)
local command, cloneError = cloneJson(commandInput, "$.command")
if cloneError then
return nil, { cloneError }
end
local errors = {}
if type(command) ~= "table" or getmetatable(command) ~= nil then
appendError(errors, "invalid_command", "$.command", "대상 선택 명령은 일반 객체여야 합니다.")
return nil, errors
end
checkAllowedKeys(
command,
{ characterId = true, interactionToken = true },
"$.command",
errors
)
if not isAsciiId(command.characterId) then
appendError(errors, "invalid_command_character", "$.command.characterId", "대상 선택에는 characterId가 필요합니다.")
end
if type(command.interactionToken) ~= "string"
or string.match(
command.interactionToken,
"^run%-progression%-character%-v1:%d+:%d+:%d+$"
) == nil then
appendError(errors, "invalid_command_token", "$.command.interactionToken", "대상 interactionToken 형식이 올바르지 않습니다.")
end
if #errors > 0 then
return nil, errors
end
return command, nil
end
local function settle(currentInput, setupInput, summaryInput, staticInput)
local setup, staticData, playerPool, characterPool, setupErrors =
validateSetup(setupInput, staticInput)
if setupErrors then
return failure(setupErrors)
end
local summary, summaryErrors = normalizeSummary(summaryInput, staticData, "$.summary")
if summaryErrors then
return failure(summaryErrors)
end
local sessions
local current
if currentInput == nil then
local initialSpec, specErrors = buildInitialBattleSpec(setup, staticInput)
if specErrors then
return failure(specErrors)
end
local matchErrors = summaryMatchesSpec(summary, initialSpec, "$.summary")
if #matchErrors > 0 then
return failure(matchErrors)
end
sessions = {
makeSessionRecord(summary),
}
else
local currentErrors
current, currentErrors = validateAndReplay(
currentInput,
setup,
staticData,
staticInput,
playerPool,
characterPool
)
if currentErrors then
return failure(currentErrors)
end
if current.lastSettlement.battleId == summary.battleId
and current.lastSettlement.turnId == summary.turnId then
if not deepEqual(current.lastSettlement, summary) then
return failure({
makeError(
"settlement_receipt_conflict",
"$.summary",
"같은 battleId와 turnId의 정산 summary가 이미 저장된 내용과 다릅니다."
),
})
end
return success(current, false, true)
end
if current.phase ~= "battleReady" then
return failure({
makeError(
"different_settlement_while_pending",
"$.summary",
"현재 정산의 보상과 다음 대상 선택을 마치기 전에는 다른 전투를 정산할 수 없습니다."
),
})
end
local matchErrors = summaryMatchesSpec(summary, current.battleSpec, "$.summary")
if #matchErrors > 0 then
return failure(matchErrors)
end
sessions = {}
for index, record in ipairs(current.sessions) do
local recordCopy, recordError = cloneJson(record, "$.state.sessions[" .. index .. "]")
if recordError then
return failure({ recordError })
end
sessions[index] = recordCopy
end
sessions[#sessions + 1] = makeSessionRecord(summary)
end
local nextState, replayErrors = replaySessions(
setup,
staticData,
staticInput,
playerPool,
characterPool,
sessions
)
if replayErrors then
return failure(replayErrors)
end
return success(nextState, true, false)
end
local function claimReward(currentInput, setupInput, commandInput, staticInput)
local command, commandErrors = validateClaimCommand(commandInput)
if commandErrors then
return failure(commandErrors)
end
local setup, staticData, playerPool, characterPool, setupErrors =
validateSetup(setupInput, staticInput)
if setupErrors then
return failure(setupErrors)
end
local current, currentErrors = validateAndReplay(
currentInput,
setup,
staticData,
staticInput,
playerPool,
characterPool
)
if currentErrors then
return failure(currentErrors)
end
if current.phase ~= "reward"
or command.interactionToken ~= current.rewardOffer.interactionToken then
return success(current, false, true)
end
local choice
if command.cardId == "continue" then
choice = {
kind = "none",
}
else
choice = {
kind = "card",
}
choice.cardId = command.cardId
end
local _, applyErrors = applyRewardChoice(
current.rewardOffer,
choice,
current.playerCardIds,
playerPool,
"$.command"
)
if applyErrors then
return failure(applyErrors)
end
local sessions, sessionsError = cloneJson(current.sessions, "$.state.sessions")
if sessionsError then
return failure({ sessionsError })
end
sessions[#sessions].rewardChoice = choice
local nextState, replayErrors = replaySessions(
setup,
staticData,
staticInput,
playerPool,
characterPool,
sessions
)
if replayErrors then
return failure(replayErrors)
end
return success(nextState, true, false)
end
local function chooseCharacter(currentInput, setupInput, commandInput, staticInput)
local command, commandErrors = validateCharacterCommand(commandInput)
if commandErrors then
return failure(commandErrors)
end
local setup, staticData, playerPool, characterPool, setupErrors =
validateSetup(setupInput, staticInput)
if setupErrors then
return failure(setupErrors)
end
local current, currentErrors = validateAndReplay(
currentInput,
setup,
staticData,
staticInput,
playerPool,
characterPool
)
if currentErrors then
return failure(currentErrors)
end
if current.phase ~= "characterSelect"
or command.interactionToken ~= current.characterOffer.interactionToken then
return success(current, false, true)
end
if not contains(current.characterOffer.characterIds, command.characterId) then
return failure({
makeError(
"character_not_in_offer",
"$.command.characterId",
"선택한 캐릭터가 현재 대상 후보에 없습니다."
),
})
end
local sessions, sessionsError = cloneJson(current.sessions, "$.state.sessions")
if sessionsError then
return failure({ sessionsError })
end
sessions[#sessions].nextCharacterId = command.characterId
local nextState, replayErrors = replaySessions(
setup,
staticData,
staticInput,
playerPool,
characterPool,
sessions
)
if replayErrors then
return failure(replayErrors)
end
return success(nextState, true, false)
end
local function validateState(currentInput, setupInput, staticInput)
local setup, staticData, playerPool, characterPool, setupErrors =
validateSetup(setupInput, staticInput)
if setupErrors then
return failure(setupErrors)
end
if currentInput == nil then
return failure({
makeError("missing_progression_state", "$.state", "검증할 runProgression 상태가 필요합니다."),
})
end
local current, currentErrors = validateAndReplay(
currentInput,
setup,
staticData,
staticInput,
playerPool,
characterPool
)
if currentErrors then
return failure(currentErrors)
end
return success(current, false, false)
end
local arguments = { ... }
if action == "settle" then
return settle(arguments[1], arguments[2], arguments[3], arguments[4])
elseif action == "claimReward" then
return claimReward(arguments[1], arguments[2], arguments[3], arguments[4])
elseif action == "chooseCharacter" then
return chooseCharacter(arguments[1], arguments[2], arguments[3], arguments[4])
elseif action == "validate" then
return validateState(arguments[1], arguments[2], arguments[3])
end
return failure({
makeError(
"unknown_action",
"$.action",
"지원하지 않는 runProgression 작업입니다: " .. tostring(action)
),
})
end)
#45runProgressionView.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local VIEW_KIND = "runProgressionView"
local STATE_KIND = "runProgressionV1"
local SETUP_KIND = "gameSetupV1"
local DECK_MIN = 10
local DECK_MAX = 20
local CARD_COPY_LIMIT = 2
local CHARACTER_OFFER_SIZE = 3
local REWARD_OFFER_MAX = 3
local VALID_PHASES = {
reward = true,
characterSelect = true,
battleReady = true,
}
local VALID_OUTCOMES = {
victory = true,
defeat = true,
}
local REASON_LABELS = {
card_checkpoint = "카드 해결 중 승패 확정",
turn_end_checkpoint = "턴 종료 판정",
turn_limit = "제한 구간 도착",
}
local function addError(errors, code, path, message)
errors[#errors + 1] = {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
errors = errors,
}
end
local function success(key, value)
local result = {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
}
if key ~= nil then
result[key] = value
end
return result
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isInteger(value, minimum, maximum)
return isFinite(value)
and value % 1 == 0
and (minimum == nil or value >= minimum)
and (maximum == nil or value <= maximum)
end
local function isAsciiId(value)
return type(value) == "string"
and string.match(value, "^[a-z][a-z0-9_]*$") ~= nil
end
local function isRuntimeId(value)
return type(value) == "string"
and string.match(value, "^[A-Za-z0-9][A-Za-z0-9_-]*$") ~= nil
end
local function isInteractionToken(value)
return type(value) == "string"
and value ~= ""
and #value <= 512
and string.find(value, "[%z\1-\31\127]") == nil
end
local function objectPath(path, key)
if type(key) == "string"
and string.match(key, "^[A-Za-z_][A-Za-z0-9_]*$") then
return path .. "." .. key
end
return path .. "[" .. string.format("%q", tostring(key)) .. "]"
end
local function inspectTable(value, path, errors)
if type(value) ~= "table" or getmetatable(value) ~= nil then
addError(errors, "expected_plain_table", path, "메타테이블이 없는 테이블이어야 합니다.")
return nil
end
local numericCount = 0
local maximum = 0
local hasNumeric = false
local hasString = false
local stringKeys = {}
for key in pairs(value) do
if type(key) == "number" then
hasNumeric = true
numericCount = numericCount + 1
if not isInteger(key, 1) then
addError(errors, "invalid_array_index", path, "배열 인덱스는 1 이상의 정수여야 합니다.")
return nil
end
if key > maximum then maximum = key end
elseif type(key) == "string" then
hasString = true
stringKeys[#stringKeys + 1] = key
else
addError(errors, "invalid_object_key", path, "객체 키는 문자열이어야 합니다.")
return nil
end
end
if hasNumeric and hasString then
addError(errors, "mixed_table", path, "배열 인덱스와 객체 키를 함께 사용할 수 없습니다.")
return nil
end
if hasNumeric then
if numericCount ~= maximum then
addError(errors, "sparse_array", path, "배열 인덱스는 1부터 빈틈없이 이어져야 합니다.")
return nil
end
return "array", maximum
end
if hasString then
table.sort(stringKeys)
return "object", stringKeys
end
return "array", 0
end
local function getArrayLength(value, path, errors)
local kind, length = inspectTable(value, path, errors)
if kind ~= "array" then
if kind ~= nil then
addError(errors, "expected_array", path, "1부터 이어지는 연속 배열이어야 합니다.")
end
return nil
end
return length
end
local function validateJsonSafe(value, path, errors, active)
local valueType = type(value)
if valueType == "string" or valueType == "boolean" then return end
if valueType == "number" then
if not isFinite(value) then
addError(errors, "non_finite_number", path, "NaN과 무한대는 View에 넣을 수 없습니다.")
end
return
end
if valueType ~= "table" then
addError(errors, "unsupported_type", path, "View에 넣을 수 없는 자료형입니다: " .. valueType)
return
end
if getmetatable(value) ~= nil then
addError(errors, "metatable_not_allowed", path, "View에는 메타테이블을 사용할 수 없습니다.")
return
end
active = active or {}
if active[value] then
addError(errors, "circular_reference", path, "순환 참조가 있는 값은 사용할 수 없습니다.")
return
end
active[value] = true
local kind, shape = inspectTable(value, path, errors)
if kind == "array" then
for index = 1, shape do
validateJsonSafe(value[index], path .. "[" .. index .. "]", errors, active)
end
elseif kind == "object" then
for _, key in ipairs(shape) do
validateJsonSafe(value[key], objectPath(path, key), errors, active)
end
end
active[value] = nil
end
local function checkAllowedKeys(value, allowed, path, errors)
if type(value) ~= "table" then return end
for key in pairs(value) do
if type(key) ~= "string" or allowed[key] ~= true then
addError(errors, "unknown_field", objectPath(path, key), "허용 목록에 없는 필드입니다.")
end
end
end
local function cloneJson(value, path, active)
if type(value) ~= "table" then return value, nil end
if getmetatable(value) ~= nil then
return nil, {
code = "metatable_not_allowed",
path = path,
message = "복제할 값에는 메타테이블을 사용할 수 없습니다.",
}
end
active = active or {}
if active[value] then
return nil, {
code = "circular_reference",
path = path,
message = "순환 참조가 있는 값을 복제할 수 없습니다.",
}
end
active[value] = true
local copy = {}
for key, item in pairs(value) do
local itemCopy, itemError = cloneJson(item, objectPath(path, key), active)
if itemError then
active[value] = nil
return nil, itemError
end
copy[key] = itemCopy
end
active[value] = nil
return copy, nil
end
local function deepEqual(left, right, active)
if type(left) ~= type(right) then return false end
if type(left) ~= "table" then return left == right end
if getmetatable(left) ~= nil or getmetatable(right) ~= nil then return false end
active = active or {}
if active[left] ~= nil then return active[left] == right end
active[left] = right
for key, value in pairs(left) do
if not deepEqual(value, right[key], active) then
active[left] = nil
return false
end
end
for key in pairs(right) do
if left[key] == nil then
active[left] = nil
return false
end
end
active[left] = nil
return true
end
local function normalizeStaticData(staticData)
if type(staticData) == "table"
and getmetatable(staticData) == nil
and type(rawget(staticData, "data")) == "table" then
return rawget(staticData, "data")
end
return staticData
end
local function appendNestedErrors(target, prefix, report)
if type(report) ~= "table" or type(report.errors) ~= "table" then
addError(target, "validation_failed", prefix, "하위 검증 결과를 읽을 수 없습니다.")
return
end
local appended = false
for _, item in ipairs(report.errors) do
appended = true
local suffix = tostring(type(item) == "table" and item.path or "$")
if string.sub(suffix, 1, 1) == "$" then
suffix = string.sub(suffix, 2)
else
suffix = "." .. suffix
end
addError(
target,
tostring(type(item) == "table" and item.code or "validation_failed"),
prefix .. suffix,
tostring(type(item) == "table" and item.message or "하위 검증에 실패했습니다.")
)
end
if not appended then
addError(target, "validation_failed", prefix, "하위 검증이 상세 오류 없이 실패했습니다.")
end
end
local function callRuntime(moduleName, moduleAction, ...)
if type(runScript) ~= "function" then
return nil, {
code = "runtime_unavailable",
path = "$.runtime." .. moduleName,
message = "runScript 실행기를 찾을 수 없습니다.",
}
end
local ok, report = pcall(runScript, triggerId, moduleName, moduleAction, ...)
if not ok then
return nil, {
code = "runtime_call_error",
path = "$.runtime." .. moduleName,
message = moduleName .. "." .. moduleAction .. " 실행에 실패했습니다: " .. tostring(report),
}
end
if type(report) ~= "table" or getmetatable(report) ~= nil then
return nil, {
code = "invalid_runtime_result",
path = "$.runtime." .. moduleName,
message = "하위 모듈이 일반 테이블 결과를 반환하지 않았습니다.",
}
end
return report, nil
end
local function inspectCanonicalInputs(runState, setupState, staticInput)
local errors = {}
if type(runState) ~= "table" or getmetatable(runState) ~= nil then
addError(errors, "invalid_canonical_run_state", "$.runState", "내부 canonical 진행 상태가 일반 테이블이 아닙니다.")
elseif runState.schemaVersion ~= SCHEMA_VERSION
or runState.kind ~= STATE_KIND
or VALID_PHASES[runState.phase] ~= true
or not isRuntimeId(runState.setupId)
or type(runState.stats) ~= "table"
or type(runState.playerCardIds) ~= "table"
or type(runState.lastSettlement) ~= "table" then
addError(errors, "invalid_canonical_run_contract", "$.runState", "내부 canonical 진행 상태가 필수 postcondition을 만족하지 않습니다.")
elseif (runState.phase == "reward" and type(runState.rewardOffer) ~= "table")
or (runState.phase == "characterSelect" and type(runState.characterOffer) ~= "table")
or (runState.phase == "battleReady" and type(runState.battleSpec) ~= "table") then
addError(errors, "invalid_canonical_run_phase", "$.runState", "내부 canonical 진행 상태와 phase 자료가 일치하지 않습니다.")
end
if type(setupState) ~= "table" or getmetatable(setupState) ~= nil then
addError(errors, "invalid_canonical_setup_state", "$.setupState", "내부 canonical 설정 상태가 일반 테이블이 아닙니다.")
elseif setupState.schemaVersion ~= SCHEMA_VERSION
or setupState.kind ~= SETUP_KIND
or setupState.phase ~= "battleReady"
or not isRuntimeId(setupState.setupId)
or (type(runState) == "table" and runState.setupId ~= setupState.setupId) then
addError(errors, "invalid_canonical_setup_contract", "$.setupState", "내부 canonical 설정 상태가 필수 postcondition을 만족하지 않습니다.")
end
local staticData = normalizeStaticData(staticInput)
if type(staticData) ~= "table"
or getmetatable(staticData) ~= nil
or type(staticData.registry) ~= "table"
or type(staticData.registry.cardTypes) ~= "table"
or type(staticData.registry.roles) ~= "table"
or type(staticData.registry.mechanisms) ~= "table"
or type(staticData.registry.moods) ~= "table"
or type(staticData.cards) ~= "table"
or type(staticData.traits) ~= "table"
or type(staticData.subwayLines) ~= "table"
or type(staticData.characters) ~= "table" then
addError(errors, "missing_static_data", "$.staticData", "검증된 전체 정적 데이터가 필요합니다.")
end
if #errors > 0 then return nil, errors end
return staticData, nil
end
local function lookupStation(line, stationId)
for _, routePath in ipairs(type(line) == "table" and line.paths or {}) do
for _, station in ipairs(type(routePath) == "table" and routePath.stations or {}) do
if type(station) == "table" and station.id == stationId then
return station
end
end
end
return nil
end
local function stationView(station)
return {
id = station.id,
code = station.code,
name = station.name,
}
end
local function buildRouteView(settlement, staticData, errors)
local transit = settlement.transit
local line = staticData.subwayLines[transit.lineId]
if type(line) ~= "table" then
addError(errors, "unknown_line", "$.result.route", "정산 노선을 찾을 수 없습니다.")
return nil
end
local ids = transit.stationIds
local reachedIndex = math.min(#ids, settlement.turnNumber + 1)
local startStation = lookupStation(line, ids[1])
local reachedStation = lookupStation(line, ids[reachedIndex])
local destinationStation = lookupStation(line, ids[#ids])
if type(startStation) ~= "table"
or type(reachedStation) ~= "table"
or type(destinationStation) ~= "table" then
addError(errors, "unknown_route_station", "$.result.route", "정산 이동 구간의 역 정보를 찾을 수 없습니다.")
return nil
end
return {
lineId = line.id,
lineName = line.name,
lineCode = line.code,
lineColor = line.color,
operatorName = line.operatorName,
startStation = stationView(startStation),
reachedStation = stationView(reachedStation),
destinationStation = stationView(destinationStation),
}
end
local function buildResultView(runState, staticData, errors)
local settlement = runState.lastSettlement
local character = staticData.characters[settlement.characterId]
if type(character) ~= "table" then
addError(errors, "missing_result_reference", "$.result", "최근 세션의 공개 참조를 찾을 수 없습니다.")
return nil
end
local route = buildRouteView(settlement, staticData, errors)
if route == nil then return nil end
local statusLabel = settlement.status == "victory" and "승리" or "패배"
local headline = settlement.status == "victory"
and character.name .. "의 저항을 무너뜨렸습니다"
or character.name .. " 공략에 실패했습니다"
return {
sessionNumber = runState.stats.completed,
battleId = settlement.battleId,
status = settlement.status,
statusLabel = statusLabel,
headline = headline,
reasonCode = settlement.reasonCode,
reasonLabel = REASON_LABELS[settlement.reasonCode],
character = {
id = character.id,
name = character.name,
},
turnNumber = settlement.turnNumber,
turnLimit = settlement.turnLimit,
finalStealth = settlement.finalStealth,
finalResistance = settlement.finalResistance,
route = route,
}
end
local function buildCardView(slot, cardId, ownedCopies, staticData, errors)
local path = "$.rewardOffer.cards[" .. slot .. "]"
local card = staticData.cards[cardId]
if type(card) ~= "table" or card.owner ~= "player" or card.id ~= cardId then
addError(errors, "unknown_reward_card", path .. ".cardId", "보상 카드 정의를 찾을 수 없습니다.")
return nil
end
local presentation, callError = callRuntime(
"viewBuilder",
"buildCardPresentation",
card,
staticData.registry,
path
)
if callError then
errors[#errors + 1] = callError
return nil
end
if presentation.ok ~= true or type(presentation.card) ~= "table" then
appendNestedErrors(errors, path, presentation)
return nil
end
if type(card.base) ~= "table"
or not isFinite(card.base.stealthCost)
or not isFinite(card.base.resistanceDamage) then
addError(errors, "invalid_card_base", path, "보상 카드의 기본 수치를 표시할 수 없습니다.")
return nil
end
return {
slot = slot,
cardId = presentation.card.cardId,
name = presentation.card.name,
descriptionSegments = presentation.card.descriptionSegments,
ruleLines = presentation.card.ruleLines,
cardType = presentation.card.cardType,
roles = presentation.card.roles,
mechanisms = presentation.card.mechanisms,
terms = presentation.card.terms,
baseStealthCost = card.base.stealthCost,
baseResistanceDamage = card.base.resistanceDamage,
ownedCopies = ownedCopies,
}
end
local function buildRewardOfferView(runState, staticData, errors)
local offer = runState.rewardOffer
local result = {
kind = offer.kind,
interactionToken = offer.interactionToken,
cards = {},
}
if offer.kind == "card" then
local copies = {}
for _, cardId in ipairs(runState.playerCardIds) do
copies[cardId] = (copies[cardId] or 0) + 1
end
for slot, cardId in ipairs(offer.cardIds) do
local cardView = buildCardView(slot, cardId, copies[cardId] or 0, staticData, errors)
if cardView ~= nil then result.cards[#result.cards + 1] = cardView end
end
end
result.count = #result.cards
return result
end
local function buildAppearanceSummary(profile, path, errors)
if type(profile) ~= "table" or type(profile.appearance) ~= "table" then
addError(errors, "missing_public_appearance", path, "공개 외형 정보를 찾을 수 없습니다.")
return nil
end
local appearance = profile.appearance
local parts = {}
local function appendText(value, suffix)
if type(value) == "string" and value ~= "" then
parts[#parts + 1] = value .. (suffix or "")
end
end
local function appendNumber(value, suffix)
if isFinite(value) then parts[#parts + 1] = tostring(value) .. suffix end
end
appendText(appearance.hairColor, " 머리")
appendText(appearance.eyeColor, " 눈")
appendText(appearance.skin)
appendNumber(appearance.height, "cm")
appendNumber(appearance.weight, "kg")
appendText(appearance.threeSize)
appendText(appearance.style)
if #parts == 0 then
addError(errors, "empty_public_appearance", path, "표시할 공개 외형 요약이 없습니다.")
return nil
end
return table.concat(parts, " · ")
end
local function buildCharacterView(slot, characterId, staticData, errors)
local path = "$.characterOffer.characters[" .. slot .. "]"
local character = staticData.characters[characterId]
if type(character) ~= "table"
or character.id ~= characterId
or type(character.name) ~= "string"
or character.name == "" then
addError(errors, "unknown_character", path .. ".characterId", "제안 캐릭터를 찾을 수 없습니다.")
return nil
end
local profile = character.publicProfile
local battle = character.battle
if type(profile) ~= "table" or type(battle) ~= "table" then
addError(errors, "missing_character_public_data", path, "캐릭터 공개 정보를 표시할 수 없습니다.")
return nil
end
local appearanceSummary = buildAppearanceSummary(profile, path .. ".appearanceSummary", errors)
local mood = staticData.registry.moods[battle.startingMood]
if type(mood) ~= "table" or type(mood.label) ~= "string" or mood.label == "" then
addError(errors, "unknown_starting_mood", path .. ".startingMood", "시작 무드를 찾을 수 없습니다.")
return nil
end
local traits = {}
for index, traitId in ipairs(type(battle.traitIds) == "table" and battle.traitIds or {}) do
local trait = staticData.traits[traitId]
if type(trait) ~= "table"
or trait.owner ~= "character"
or trait.visibility ~= "public"
or type(trait.name) ~= "string"
or type(trait.description) ~= "string" then
addError(errors, "invalid_public_trait", path .. ".traits[" .. index .. "]", "공개 특징을 표시할 수 없습니다.")
else
traits[#traits + 1] = {
id = trait.id,
name = trait.name,
description = trait.description,
}
end
end
return {
slot = slot,
characterId = character.id,
name = character.name,
age = profile.age,
occupation = profile.occupation,
appearanceSummary = appearanceSummary,
startingResistance = battle.startingResistance,
turnLimit = battle.turnLimit,
startingMood = {
id = mood.id,
label = mood.label,
},
baseDrawCount = battle.baseDrawCount,
maxHandSize = battle.maxHandSize,
traits = traits,
}
end
local function buildCharacterOfferView(runState, staticData, errors)
local characters = {}
for slot, characterId in ipairs(runState.characterOffer.characterIds) do
local characterView = buildCharacterView(slot, characterId, staticData, errors)
if characterView ~= nil then characters[#characters + 1] = characterView end
end
return {
interactionToken = runState.characterOffer.interactionToken,
characters = characters,
}
end
local function validateString(value, path, errors)
if type(value) ~= "string" or value == "" then
addError(errors, "invalid_text", path, "비어 있지 않은 문자열이어야 합니다.")
end
end
local function validateStationView(value, path, errors)
if type(value) ~= "table" then
addError(errors, "invalid_station_view", path, "역 View가 테이블이 아닙니다.")
return
end
checkAllowedKeys(value, { id = true, code = true, name = true }, path, errors)
if not isAsciiId(value.id) then
addError(errors, "invalid_station_id", path .. ".id", "역 ID가 올바르지 않습니다.")
end
validateString(value.code, path .. ".code", errors)
validateString(value.name, path .. ".name", errors)
end
local function validateTagView(value, path, errors, expectedKind)
if type(value) ~= "table" then
addError(errors, "invalid_tag_view", path, "태그 View가 테이블이 아닙니다.")
return
end
checkAllowedKeys(value, {
kind = true,
id = true,
label = true,
tagKind = true,
tooltip = true,
}, path, errors)
if value.kind ~= "tag" or not isAsciiId(value.id) then
addError(errors, "invalid_tag_identity", path, "태그 View 식별자가 올바르지 않습니다.")
end
if value.tagKind ~= "type" and value.tagKind ~= "role" and value.tagKind ~= "mechanism" and value.tagKind ~= "term" then
addError(errors, "invalid_tag_kind", path .. ".tagKind", "태그 종류가 올바르지 않습니다.")
elseif expectedKind ~= nil and value.tagKind ~= expectedKind then
addError(errors, "tag_kind_mismatch", path .. ".tagKind", "이 위치의 태그 종류가 다릅니다.")
end
validateString(value.label, path .. ".label", errors)
validateString(value.tooltip, path .. ".tooltip", errors)
end
local function validateSegments(value, path, errors)
local length = getArrayLength(value, path, errors)
if length == nil then return end
for index = 1, length do
local segment = value[index]
local segmentPath = path .. "[" .. index .. "]"
if type(segment) ~= "table" then
addError(errors, "invalid_segment", segmentPath, "문장 조각이 테이블이 아닙니다.")
elseif segment.kind == "text" then
checkAllowedKeys(segment, { kind = true, value = true }, segmentPath, errors)
validateString(segment.value, segmentPath .. ".value", errors)
elseif segment.kind == "tag" then
validateTagView(segment, segmentPath, errors)
else
addError(errors, "invalid_segment_kind", segmentPath .. ".kind", "문장 조각 종류가 올바르지 않습니다.")
end
end
end
local function validateRuleLines(value, path, errors)
local length = getArrayLength(value, path, errors)
if length == nil then return end
if length < 1 then
addError(errors, "empty_rule_lines", path, "카드 규칙은 한 줄 이상이어야 합니다.")
end
for index = 1, length do
local line = value[index]
local linePath = path .. "[" .. index .. "]"
if type(line) ~= "table" then
addError(errors, "invalid_rule_line", linePath, "규칙 줄이 테이블이 아닙니다.")
else
checkAllowedKeys(line, { segments = true }, linePath, errors)
validateSegments(line.segments, linePath .. ".segments", errors)
end
end
end
local function validateCardView(card, path, expectedSlot, errors)
if type(card) ~= "table" then
addError(errors, "invalid_card_view", path, "보상 카드 View가 테이블이 아닙니다.")
return
end
checkAllowedKeys(card, {
slot = true,
cardId = true,
name = true,
descriptionSegments = true,
ruleLines = true,
cardType = true,
roles = true,
mechanisms = true,
terms = true,
baseStealthCost = true,
baseResistanceDamage = true,
ownedCopies = true,
}, path, errors)
if card.slot ~= expectedSlot then
addError(errors, "card_slot_mismatch", path .. ".slot", "카드 슬롯이 배열 순서와 다릅니다.")
end
if not isAsciiId(card.cardId) then
addError(errors, "invalid_card_id", path .. ".cardId", "카드 ID가 올바르지 않습니다.")
end
validateString(card.name, path .. ".name", errors)
validateSegments(card.descriptionSegments, path .. ".descriptionSegments", errors)
validateRuleLines(card.ruleLines, path .. ".ruleLines", errors)
validateTagView(card.cardType, path .. ".cardType", errors, "type")
local roleCount = getArrayLength(card.roles, path .. ".roles", errors)
if roleCount ~= nil then
if roleCount < 1 or roleCount > 2 then
addError(errors, "invalid_card_roles", path .. ".roles", "플레이어 카드는 역할 태그를 하나 또는 둘 가져야 합니다.")
end
for index = 1, roleCount do
validateTagView(card.roles[index], path .. ".roles[" .. index .. "]", errors, "role")
end
end
local mechanismCount = getArrayLength(card.mechanisms, path .. ".mechanisms", errors)
if mechanismCount ~= nil then
local seen = {}
for index = 1, mechanismCount do
local mechanism = card.mechanisms[index]
validateTagView(mechanism, path .. ".mechanisms[" .. index .. "]", errors, "mechanism")
if type(mechanism) == "table" and type(mechanism.id) == "string" then
if seen[mechanism.id] then
addError(errors, "duplicate_mechanism", path .. ".mechanisms[" .. index .. "]", "메커니즘이 중복되었습니다.")
end
seen[mechanism.id] = true
end
end
end
local termCount = getArrayLength(card.terms, path .. ".terms", errors)
if termCount ~= nil then
local seen = {}
for index = 1, termCount do
local term = card.terms[index]
validateTagView(term, path .. ".terms[" .. index .. "]", errors, "term")
if type(term) == "table" and type(term.id) == "string" then
if seen[term.id] then
addError(errors, "duplicate_term", path .. ".terms[" .. index .. "]", "규칙 용어가 중복되었습니다.")
end
seen[term.id] = true
end
end
end
if not isFinite(card.baseStealthCost) or card.baseStealthCost < 0 then
addError(errors, "invalid_stealth_cost", path .. ".baseStealthCost", "기본 은폐 비용이 올바르지 않습니다.")
end
if not isFinite(card.baseResistanceDamage) or card.baseResistanceDamage < 0 then
addError(errors, "invalid_resistance_damage", path .. ".baseResistanceDamage", "기본 저항 피해가 올바르지 않습니다.")
end
if not isInteger(card.ownedCopies, 0, CARD_COPY_LIMIT - 1) then
addError(errors, "invalid_owned_copies", path .. ".ownedCopies", "보상 카드 보유 수는 0 또는 1이어야 합니다.")
end
end
local function validateCharacterView(character, path, expectedSlot, errors)
if type(character) ~= "table" then
addError(errors, "invalid_character_view", path, "캐릭터 View가 테이블이 아닙니다.")
return
end
checkAllowedKeys(character, {
slot = true,
characterId = true,
name = true,
age = true,
occupation = true,
appearanceSummary = true,
startingResistance = true,
turnLimit = true,
startingMood = true,
baseDrawCount = true,
maxHandSize = true,
traits = true,
}, path, errors)
if character.slot ~= expectedSlot then
addError(errors, "character_slot_mismatch", path .. ".slot", "캐릭터 슬롯이 배열 순서와 다릅니다.")
end
if not isAsciiId(character.characterId) then
addError(errors, "invalid_character_id", path .. ".characterId", "캐릭터 ID가 올바르지 않습니다.")
end
validateString(character.name, path .. ".name", errors)
if not isInteger(character.age, 1) then
addError(errors, "invalid_character_age", path .. ".age", "캐릭터 나이가 올바르지 않습니다.")
end
validateString(character.occupation, path .. ".occupation", errors)
validateString(character.appearanceSummary, path .. ".appearanceSummary", errors)
if not isFinite(character.startingResistance) or character.startingResistance <= 0 then
addError(errors, "invalid_starting_resistance", path .. ".startingResistance", "시작 저항은 양수여야 합니다.")
end
if not isInteger(character.turnLimit, 7, 12) then
addError(errors, "invalid_turn_limit", path .. ".turnLimit", "제한 턴은 7 이상 12 이하의 정수여야 합니다.")
end
if type(character.startingMood) ~= "table" then
addError(errors, "invalid_starting_mood", path .. ".startingMood", "시작 무드 View가 필요합니다.")
else
checkAllowedKeys(character.startingMood, { id = true, label = true }, path .. ".startingMood", errors)
if not isAsciiId(character.startingMood.id) then
addError(errors, "invalid_mood_id", path .. ".startingMood.id", "무드 ID가 올바르지 않습니다.")
end
validateString(character.startingMood.label, path .. ".startingMood.label", errors)
end
if not isInteger(character.baseDrawCount, 1)
or not isInteger(character.maxHandSize, 1)
or (isInteger(character.baseDrawCount, 1)
and isInteger(character.maxHandSize, 1)
and character.baseDrawCount > character.maxHandSize) then
addError(errors, "invalid_character_hand_values", path, "드로우와 손패 상한이 올바르지 않습니다.")
end
local traitCount = getArrayLength(character.traits, path .. ".traits", errors)
if traitCount ~= nil then
local seen = {}
for index = 1, traitCount do
local trait = character.traits[index]
local traitPath = path .. ".traits[" .. index .. "]"
if type(trait) ~= "table" then
addError(errors, "invalid_trait_view", traitPath, "특징 View가 테이블이 아닙니다.")
else
checkAllowedKeys(trait, { id = true, name = true, description = true }, traitPath, errors)
if not isAsciiId(trait.id) then
addError(errors, "invalid_trait_id", traitPath .. ".id", "특징 ID가 올바르지 않습니다.")
elseif seen[trait.id] then
addError(errors, "duplicate_trait", traitPath .. ".id", "특징이 중복되었습니다.")
else
seen[trait.id] = true
end
validateString(trait.name, traitPath .. ".name", errors)
validateString(trait.description, traitPath .. ".description", errors)
end
end
end
end
local function validateRunProgressionView(view)
local errors = {}
validateJsonSafe(view, "$", errors)
if #errors > 0 then return failure(errors) end
if type(view) ~= "table" or getmetatable(view) ~= nil then
addError(errors, "invalid_view_root", "$", "runProgressionView 최상위 값은 일반 테이블이어야 합니다.")
return failure(errors)
end
checkAllowedKeys(view, {
schemaVersion = true,
kind = true,
phase = true,
sessionIndex = true,
stats = true,
deck = true,
result = true,
rewardOffer = true,
characterOffer = true,
selectedCharacter = true,
}, "$", errors)
if view.schemaVersion ~= SCHEMA_VERSION then
addError(errors, "unsupported_schema", "$.schemaVersion", "지원하지 않는 runProgressionView 스키마입니다.")
end
if view.kind ~= VIEW_KIND then
addError(errors, "invalid_view_kind", "$.kind", "View kind가 runProgressionView가 아닙니다.")
end
if VALID_PHASES[view.phase] ~= true then
addError(errors, "invalid_phase", "$.phase", "지원하지 않는 View phase입니다.")
end
if not isInteger(view.sessionIndex, 1) then
addError(errors, "invalid_session_index", "$.sessionIndex", "세션 순번은 1 이상의 정수여야 합니다.")
end
if type(view.stats) ~= "table" then
addError(errors, "invalid_stats", "$.stats", "누적 전적 View가 필요합니다.")
else
checkAllowedKeys(view.stats, {
completed = true,
victories = true,
defeats = true,
}, "$.stats", errors)
for _, field in ipairs({ "completed", "victories", "defeats" }) do
if not isInteger(view.stats[field], 0) then
addError(errors, "invalid_stat", "$.stats." .. field, "누적 전적은 0 이상의 정수여야 합니다.")
end
end
if isInteger(view.stats.completed, 0)
and isInteger(view.stats.victories, 0)
and isInteger(view.stats.defeats, 0)
and view.stats.completed ~= view.stats.victories + view.stats.defeats then
addError(errors, "stats_total_mismatch", "$.stats", "완료 수가 승리와 패배 합과 다릅니다.")
end
end
if type(view.deck) ~= "table" then
addError(errors, "invalid_deck", "$.deck", "덱 요약 View가 필요합니다.")
else
checkAllowedKeys(view.deck, { count = true, limit = true }, "$.deck", errors)
if not isInteger(view.deck.count, DECK_MIN, DECK_MAX) then
addError(errors, "invalid_deck_count", "$.deck.count", "덱 수는 10장 이상 20장 이하여야 합니다.")
end
if view.deck.limit ~= DECK_MAX then
addError(errors, "invalid_deck_limit", "$.deck.limit", "덱 상한 표시는 20이어야 합니다.")
end
end
if type(view.result) ~= "table" then
addError(errors, "invalid_result", "$.result", "최근 세션 결과 View가 필요합니다.")
else
checkAllowedKeys(view.result, {
sessionNumber = true,
battleId = true,
status = true,
statusLabel = true,
headline = true,
reasonCode = true,
reasonLabel = true,
character = true,
turnNumber = true,
turnLimit = true,
finalStealth = true,
finalResistance = true,
route = true,
}, "$.result", errors)
if not isInteger(view.result.sessionNumber, 1) then
addError(errors, "invalid_result_session", "$.result.sessionNumber", "결과 세션 번호가 올바르지 않습니다.")
elseif type(view.stats) == "table"
and isInteger(view.stats.completed, 0)
and view.result.sessionNumber ~= view.stats.completed then
addError(errors, "result_session_mismatch", "$.result.sessionNumber", "결과 세션 번호와 완료 전적이 다릅니다.")
end
if not isRuntimeId(view.result.battleId) then
addError(errors, "invalid_result_battle", "$.result.battleId", "결과 battleId가 올바르지 않습니다.")
end
if VALID_OUTCOMES[view.result.status] ~= true then
addError(errors, "invalid_result_status", "$.result.status", "결과 상태가 올바르지 않습니다.")
end
validateString(view.result.statusLabel, "$.result.statusLabel", errors)
validateString(view.result.headline, "$.result.headline", errors)
if REASON_LABELS[view.result.reasonCode] == nil then
addError(errors, "invalid_result_reason", "$.result.reasonCode", "결과 종료 사유가 올바르지 않습니다.")
end
validateString(view.result.reasonLabel, "$.result.reasonLabel", errors)
if type(view.result.character) ~= "table" then
addError(errors, "invalid_result_character", "$.result.character", "결과 상대 View가 필요합니다.")
else
checkAllowedKeys(view.result.character, { id = true, name = true }, "$.result.character", errors)
if not isAsciiId(view.result.character.id) then
addError(errors, "invalid_character_id", "$.result.character.id", "결과 상대 ID가 올바르지 않습니다.")
end
validateString(view.result.character.name, "$.result.character.name", errors)
end
if not isInteger(view.result.turnNumber, 1)
or not isInteger(view.result.turnLimit, 1)
or (isInteger(view.result.turnNumber, 1)
and isInteger(view.result.turnLimit, 1)
and view.result.turnNumber > view.result.turnLimit) then
addError(errors, "invalid_result_turn", "$.result", "결과 턴 수치가 올바르지 않습니다.")
end
if not isFinite(view.result.finalStealth)
or not isFinite(view.result.finalResistance) then
addError(errors, "invalid_result_resources", "$.result", "최종 자원 수치가 올바르지 않습니다.")
end
if type(view.result.route) ~= "table" then
addError(errors, "invalid_route_view", "$.result.route", "결과 노선 View가 필요합니다.")
else
checkAllowedKeys(view.result.route, {
lineId = true,
lineName = true,
lineCode = true,
lineColor = true,
operatorName = true,
startStation = true,
reachedStation = true,
destinationStation = true,
}, "$.result.route", errors)
if not isAsciiId(view.result.route.lineId) then
addError(errors, "invalid_line_id", "$.result.route.lineId", "노선 ID가 올바르지 않습니다.")
end
for _, field in ipairs({ "lineName", "lineCode", "lineColor", "operatorName" }) do
validateString(view.result.route[field], "$.result.route." .. field, errors)
end
validateStationView(view.result.route.startStation, "$.result.route.startStation", errors)
validateStationView(view.result.route.reachedStation, "$.result.route.reachedStation", errors)
validateStationView(view.result.route.destinationStation, "$.result.route.destinationStation", errors)
end
end
if view.phase == "reward" then
if type(view.rewardOffer) ~= "table" then
addError(errors, "missing_reward_offer", "$.rewardOffer", "보상 단계에는 보상 제안 View가 필요합니다.")
else
checkAllowedKeys(view.rewardOffer, {
kind = true,
interactionToken = true,
count = true,
cards = true,
}, "$.rewardOffer", errors)
if view.rewardOffer.kind ~= "card" and view.rewardOffer.kind ~= "none" then
addError(errors, "invalid_reward_kind", "$.rewardOffer.kind", "보상 종류가 올바르지 않습니다.")
end
if not isInteractionToken(view.rewardOffer.interactionToken) then
addError(errors, "invalid_interaction_token", "$.rewardOffer.interactionToken", "보상 token이 올바르지 않습니다.")
end
local cardCount = getArrayLength(view.rewardOffer.cards, "$.rewardOffer.cards", errors)
if cardCount ~= nil then
if view.rewardOffer.count ~= cardCount then
addError(errors, "reward_count_mismatch", "$.rewardOffer.count", "보상 카드 수 요약이 배열과 다릅니다.")
end
if view.rewardOffer.kind == "card"
and (cardCount < 1 or cardCount > REWARD_OFFER_MAX) then
addError(errors, "invalid_reward_card_count", "$.rewardOffer.cards", "카드 보상은 한 장 이상 세 장 이하여야 합니다.")
elseif view.rewardOffer.kind == "none" and cardCount ~= 0 then
addError(errors, "unexpected_reward_cards", "$.rewardOffer.cards", "none 보상에는 카드가 없어야 합니다.")
end
local seenCards = {}
for index = 1, cardCount do
local card = view.rewardOffer.cards[index]
validateCardView(card, "$.rewardOffer.cards[" .. index .. "]", index, errors)
if type(card) == "table" and type(card.cardId) == "string" then
if seenCards[card.cardId] then
addError(errors, "duplicate_reward_card", "$.rewardOffer.cards[" .. index .. "]", "보상 카드가 중복되었습니다.")
end
seenCards[card.cardId] = true
end
end
end
end
if view.characterOffer ~= nil or view.selectedCharacter ~= nil then
addError(errors, "unexpected_phase_view", "$", "보상 단계에 상대 선택 View를 둘 수 없습니다.")
end
elseif view.phase == "characterSelect" then
if type(view.characterOffer) ~= "table" then
addError(errors, "missing_character_offer", "$.characterOffer", "상대 선택 View가 필요합니다.")
else
checkAllowedKeys(view.characterOffer, {
interactionToken = true,
characters = true,
}, "$.characterOffer", errors)
if not isInteractionToken(view.characterOffer.interactionToken) then
addError(errors, "invalid_interaction_token", "$.characterOffer.interactionToken", "상대 선택 token이 올바르지 않습니다.")
end
local characterCount = getArrayLength(view.characterOffer.characters, "$.characterOffer.characters", errors)
if characterCount ~= nil then
if characterCount ~= CHARACTER_OFFER_SIZE then
addError(errors, "invalid_character_offer_size", "$.characterOffer.characters", "다음 상대는 정확히 세 명이어야 합니다.")
end
local seenCharacters = {}
for index = 1, characterCount do
local character = view.characterOffer.characters[index]
validateCharacterView(character, "$.characterOffer.characters[" .. index .. "]", index, errors)
if type(character) == "table" and type(character.characterId) == "string" then
if seenCharacters[character.characterId] then
addError(errors, "duplicate_character", "$.characterOffer.characters[" .. index .. "]", "같은 상대가 중복되었습니다.")
end
seenCharacters[character.characterId] = true
end
end
end
end
if view.rewardOffer ~= nil or view.selectedCharacter ~= nil then
addError(errors, "unexpected_phase_view", "$", "상대 선택 단계에 보상 또는 선택 완료 View를 둘 수 없습니다.")
end
elseif view.phase == "battleReady" then
if type(view.selectedCharacter) ~= "table" then
addError(errors, "missing_selected_character", "$.selectedCharacter", "선택 완료 상대 View가 필요합니다.")
else
checkAllowedKeys(view.selectedCharacter, { characterId = true, name = true }, "$.selectedCharacter", errors)
if not isAsciiId(view.selectedCharacter.characterId) then
addError(errors, "invalid_character_id", "$.selectedCharacter.characterId", "선택 상대 ID가 올바르지 않습니다.")
end
validateString(view.selectedCharacter.name, "$.selectedCharacter.name", errors)
end
if view.rewardOffer ~= nil or view.characterOffer ~= nil then
addError(errors, "unexpected_phase_view", "$", "전투 준비 단계에 제안 View를 둘 수 없습니다.")
end
end
if #errors > 0 then return failure(errors) end
return success("valid", true)
end
local function buildCanonicalView(runState, setupState, staticInput)
local staticData, inputErrors = inspectCanonicalInputs(runState, setupState, staticInput)
if inputErrors then return failure(inputErrors) end
local errors = {}
local statsCopy, statsError = cloneJson(runState.stats, "$.stats")
if statsError then return failure({ statsError }) end
local resultView = buildResultView(runState, staticData, errors)
local view = {
schemaVersion = SCHEMA_VERSION,
kind = VIEW_KIND,
phase = runState.phase,
sessionIndex = runState.sessionNumber,
stats = statsCopy,
deck = {
count = #runState.playerCardIds,
limit = DECK_MAX,
},
result = resultView,
}
if runState.phase == "reward" then
view.rewardOffer = buildRewardOfferView(runState, staticData, errors)
elseif runState.phase == "characterSelect" then
view.characterOffer = buildCharacterOfferView(runState, staticData, errors)
elseif runState.phase == "battleReady" then
local characterId = type(runState.battleSpec) == "table"
and runState.battleSpec.characterId
or nil
local character = staticData.characters[characterId]
if type(character) ~= "table" then
addError(errors, "unknown_selected_character", "$.selectedCharacter", "선택한 상대 공개 정보를 찾을 수 없습니다.")
else
view.selectedCharacter = {
characterId = character.id,
name = character.name,
}
end
end
if #errors > 0 then return failure(errors) end
local validation = validateRunProgressionView(view)
if validation.ok ~= true then return validation end
return success("view", view)
end
local function buildPublic(runInput, setupInput, staticInput)
local setupReport, setupCallError = callRuntime(
"gameSetup",
"validate",
setupInput,
staticInput
)
if setupCallError then return failure({ setupCallError }) end
if setupReport.ok ~= true or type(setupReport.state) ~= "table" then
local errors = {}
appendNestedErrors(errors, "$.setupState", setupReport)
return failure(errors)
end
if not deepEqual(setupInput, setupReport.state) then
return failure({
{
code = "setup_validation_mismatch",
path = "$.setupState",
message = "gameSetup.validate의 canonical 상태가 입력과 정확히 일치하지 않습니다.",
},
})
end
local runReport, runCallError = callRuntime(
"runProgression",
"validate",
runInput,
setupReport.state,
staticInput
)
if runCallError then return failure({ runCallError }) end
if runReport.ok ~= true or type(runReport.state) ~= "table" then
local errors = {}
appendNestedErrors(errors, "$.runState", runReport)
return failure(errors)
end
if not deepEqual(runInput, runReport.state) then
return failure({
{
code = "run_validation_mismatch",
path = "$.runState",
message = "runProgression.validate의 canonical 상태가 입력과 정확히 일치하지 않습니다.",
},
})
end
return buildCanonicalView(runReport.state, setupReport.state, staticInput)
end
local function capabilityAllows(capability)
if type(capability) ~= "function" then return false end
local ok, allowed = pcall(capability, "runProgressionViewCanonicalV1")
return ok and allowed == true
end
local arguments = { ... }
if action == "validate" then
return validateRunProgressionView(arguments[1])
elseif action == "build" then
return buildPublic(arguments[1], arguments[2], arguments[3])
elseif action == "_buildCanonical" then
if not capabilityAllows(arguments[4]) then
return failure({
{
code = "internal_action_denied",
path = "$.action",
message = "내부 canonical View 작업에 접근할 수 없습니다.",
},
})
end
return buildCanonicalView(arguments[1], arguments[2], arguments[3])
end
return failure({
{
code = "unknown_action",
path = "$.action",
message = "지원하지 않는 runProgressionView 작업입니다: " .. tostring(action),
},
})
end)
#46runtime.lua
Keys:
(function()
--기본 변수
DEBUG = 2 --디버그 메시지 depth. 숫자가 클수록 덜 중요하고 디테일 한 메시지.
--디버깅
function debug(depth, ...)
if depth <= DEBUG then
print(...)
end
end
--tonumber를 콤마제거후 숫자로 인식하도록 오버라이딩
old_tonumber = tonumber
tonumber = function(str, base)
if type(str) == "string" then
local cleaned_str = string.gsub(str, ",", "")
return old_tonumber(cleaned_str, base)
else
return old_tonumber(str, base)
end
end
--json을 chatVar 형식으로 저장
function toChatVar(triggerId, name, data)
if data == nil then
data = HostCompat.readState(triggerId, name)
end
if type(data) ~= "table" then
HostCompat.writeChatVar(triggerId, name, tostring(data))
return tostring(data)
end
local function encodeString(value)
return json.encode(tostring(value))
end
local function encodeForChatVar(value)
local parts = {}
for k, v in pairs(value) do
local encodedValue
if type(v) == "table" then
encodedValue = encodeString(encodeForChatVar(v))
else
encodedValue = encodeString(v)
end
table.insert(parts, encodeString(k) .. ":" .. encodedValue)
end
return "{" .. table.concat(parts, ",") .. "}"
end
local encoded = encodeForChatVar(data)
HostCompat.writeChatVar(triggerId, name, encoded)
return encoded
end
--chatVar를 state 형식으로 저장
function toState(triggerId, name)
local source = getChatVar(triggerId, name)
if not source or source == "" then
debug(1, "toState error: chat var " .. tostring(name) .. " is empty.")
local empty = {}
HostCompat.writeState(triggerId, name, empty)
return empty
end
local function tryDecode(value)
if type(value) ~= "string" then
return nil
end
local trimmed = value:match("^%s*(.-)%s*$")
local first = trimmed:sub(1, 1)
if first ~= "{" and first ~= "[" then
return nil
end
local ok, decoded = pcall(json.decode, trimmed)
if ok and type(decoded) == "table" then
return decoded
end
return nil
end
local function decodeFromChatVar(value)
if type(value) == "table" then
local decoded = {}
for k, v in pairs(value) do
decoded[k] = decodeFromChatVar(v)
end
return decoded
end
local decoded = tryDecode(value)
if decoded then
return decodeFromChatVar(decoded)
end
return value
end
local root = tryDecode(source)
if not root then
HostCompat.writeState(triggerId, name, source)
return source
end
local decoded = decodeFromChatVar(root)
HostCompat.writeState(triggerId, name, decoded)
return decoded
end
local function stripHtmlLineIndent(source)
source = source:gsub("^[ \t]+", "")
source = source:gsub("\r\n[ \t]+", "\r\n")
source = source:gsub("\n[ \t]+", "\n")
source = source:gsub("\r[ \t]+", "\r")
return source
end
--로어북에서 내용을 불러와 return
function loadLores(triggerId, lore)
local targetLores = getLoreBooks(triggerId, lore)
if type(targetLores) ~= "table" then
return nil
end
local contents = {}
for _, loreEntry in ipairs(targetLores) do
table.insert(contents, loreEntry.content)
end
local returnContents = table.concat(contents)
--주석 제거
returnContents = returnContents
:gsub("%-%-%-[^\r\n]*", "")
if type(lore) == "string" and string.match(lore, "%.html$") then
returnContents = stripHtmlLineIndent(returnContents)
end
if returnContents == "" then
return nil
else
return returnContents
end
end
-- Executable lore entries are complete modules, not mergeable fragments. RisuAI
-- returns matches in local -> global -> module order. This game's authoritative
-- runtime bundle is the module entry, so the last non-empty match must replace
-- stale character/global copies. Concatenating duplicate modules can turn two
-- adjacent anonymous functions into an accidental function call.
local function loadScriptLore(triggerId, script)
local loreName = script .. ".lua"
local targetLores = getLoreBooks(triggerId, loreName)
if type(targetLores) ~= "table" then
return nil
end
local source = nil
local sourceCount = 0
for _, loreEntry in ipairs(targetLores) do
local content = type(loreEntry) == "table" and loreEntry.content or nil
if type(content) == "string" and content ~= "" then
sourceCount = sourceCount + 1
source = content
end
end
if sourceCount > 1 then
debug(2, "runScript warning: duplicate lorebook '" .. loreName
.. "'; using the last match.")
end
if source == nil then
return nil
end
return source:gsub("%-%-%-[^\r\n]*", "")
end
-- 배포 시 executable Lua lore 또는 static DB가 바뀌면 이 revision도 반드시 올린다. production
-- warm path는 이 명시적 계약 덕분에 이벤트 사이에서 getLoreBooks/CBS bridge를
-- 생략한다. 개발 중에는 setRunScriptCacheDevelopmentMode(true)를 사용하면 매
-- 이벤트의 첫 모듈 호출에서 source를 다시 확인한다.
RUNTIME_BUNDLE_REVISION = RUNTIME_BUNDLE_REVISION
or "runtime-bundle-main-split-v1-20260804"
RUNTIME_CACHE_DEVELOPMENT_BYPASS = RUNTIME_CACHE_DEVELOPMENT_BYPASS == true
local RUN_SCRIPT_SOURCE_CACHE_MAX_ENTRIES = 64
local RUN_SCRIPT_WARM_CACHE_MAX_ENTRIES = 128
local RUN_SCRIPT_TRANSACTION_MAX_ENTRIES = 64
local RUN_SCRIPT_NAMESPACE_VAR = "runtimeModuleCacheV1.namespace"
local function fingerprintRunScriptIdentity(source)
local first = 0
local second = 0
for index = 1, #source do
local byte = string.byte(source, index)
first = (first * 131 + byte) % 2147483647
second = (second * 137 + byte + index) % 2147483629
end
return tostring(#source) .. ":" .. tostring(first) .. ":" .. tostring(second)
end
local function runtimeCacheKeyPart(value)
local text = tostring(value or "")
return tostring(#text) .. ":" .. text
end
local function makeRunScriptWarmKey(event, script)
return table.concat({
runtimeCacheKeyPart(event.bundleRevision),
runtimeCacheKeyPart(event.mode),
runtimeCacheKeyPart(event.namespace),
runtimeCacheKeyPart(event.characterIdentity),
runtimeCacheKeyPart(script),
}, "|")
end
local function touchRunScriptCacheEntry(entry)
runScriptCacheClock = runScriptCacheClock + 1
entry.lastUsed = runScriptCacheClock
end
local function currentRunScriptBundleRevision()
local revision = tostring(RUNTIME_BUNDLE_REVISION or "")
if revision == "" then
return "invalid-empty-revision"
end
return revision
end
local function resolveRunScriptNamespace(triggerId)
runScriptCacheStats.namespaceReads = runScriptCacheStats.namespaceReads + 1
if type(getChatVar) == "function" then
local readOk, existing = pcall(getChatVar, triggerId, RUN_SCRIPT_NAMESPACE_VAR)
if readOk
and type(existing) == "string"
and string.match(existing, "^runtime%-ns%-v1:%d+:%d+:%d+$") then
return existing
end
end
local generated = "runtime-ns-v1:" .. fingerprintRunScriptIdentity(tostring(triggerId or ""))
if type(HostCompat) == "table" and type(HostCompat.writeChatVar) == "function" then
local writeOk = pcall(HostCompat.writeChatVar, triggerId, RUN_SCRIPT_NAMESPACE_VAR, generated)
if writeOk then
runScriptCacheStats.namespaceWrites = runScriptCacheStats.namespaceWrites + 1
local readOk, stored = pcall(getChatVar, triggerId, RUN_SCRIPT_NAMESPACE_VAR)
if readOk and stored == generated then
return generated
end
end
runScriptCacheStats.namespaceWriteFailures = runScriptCacheStats.namespaceWriteFailures + 1
end
-- 권한이 없는 mode에서는 event access key fingerprint를 사용한다. access key는
-- top-level 호출마다 달라 warm hit는 포기하지만 다른 채팅과 섞이지 않는다.
runScriptCacheStats.volatileNamespaces = runScriptCacheStats.volatileNamespaces + 1
return "volatile:" .. generated
end
local function resolveRunScriptCharacterIdentity(triggerId)
local name = ""
local firstMessage = ""
if type(getName) == "function" then
local ok, value = pcall(getName, triggerId)
if ok and type(value) == "string" then
name = value
end
end
if type(getCharacterFirstMessage) == "function" then
local ok, value = pcall(getCharacterFirstMessage, triggerId)
if ok and type(value) == "string" then
firstMessage = value
end
end
return fingerprintRunScriptIdentity(name .. "\0" .. firstMessage)
end
-- 모든 host hook의 시작에서 호출한다. namespace/character host read는 실제로
-- runScript가 호출될 때까지 지연하므로 editInput 같은 가벼운 hook에는 비용이 없다.
function beginRunScriptEvent(triggerId, mode)
currentRuntimeMode = type(mode) == "string" and mode ~= "" and mode or "unscoped"
activeRuntimeEvent = {
triggerId = triggerId,
mode = currentRuntimeMode,
bundleRevision = currentRunScriptBundleRevision(),
cacheEpoch = runScriptCacheEpoch,
handlers = {},
handlerCount = 0,
}
runScriptCacheStats.events = runScriptCacheStats.events + 1
return currentRuntimeMode
end
local function ensureRunScriptEvent(triggerId)
local revision = currentRunScriptBundleRevision()
if activeRuntimeEvent == nil
or activeRuntimeEvent.triggerId ~= triggerId
or activeRuntimeEvent.bundleRevision ~= revision
or activeRuntimeEvent.cacheEpoch ~= runScriptCacheEpoch then
beginRunScriptEvent(triggerId, currentRuntimeMode)
end
if activeRuntimeEvent.namespace == nil then
activeRuntimeEvent.namespace = resolveRunScriptNamespace(triggerId)
activeRuntimeEvent.characterIdentity = resolveRunScriptCharacterIdentity(triggerId)
end
return activeRuntimeEvent
end
local function evictOldestRunScriptSourceEntry()
if runScriptSourceCacheSize < RUN_SCRIPT_SOURCE_CACHE_MAX_ENTRIES then
return
end
local oldestScript = nil
local oldestSource = nil
local oldestEntry = nil
for scriptName, bucket in pairs(runScriptSourceBuckets) do
for source, entry in pairs(bucket) do
if oldestEntry == nil or entry.lastUsed < oldestEntry.lastUsed then
oldestScript = scriptName
oldestSource = source
oldestEntry = entry
end
end
end
if oldestEntry ~= nil then
local bucket = runScriptSourceBuckets[oldestScript]
bucket[oldestSource] = nil
if next(bucket) == nil then
runScriptSourceBuckets[oldestScript] = nil
end
runScriptSourceCacheSize = runScriptSourceCacheSize - 1
runScriptCacheStats.sourceEvictions = runScriptCacheStats.sourceEvictions + 1
end
end
local function instantiateRunScriptHandler(script, source)
local bucket = runScriptSourceBuckets[script]
local entry = bucket and bucket[source] or nil
local chunk = nil
if entry ~= nil then
runScriptCacheStats.sourceHits = runScriptCacheStats.sourceHits + 1
entry.hits = entry.hits + 1
touchRunScriptCacheEntry(entry)
chunk = entry.chunk
else
runScriptCacheStats.sourceMisses = runScriptCacheStats.sourceMisses + 1
local functionString = "return" .. source
debug(3, functionString)
local err = nil
chunk, err = load(functionString, "lore_function:" .. tostring(script), "t", _G)
if not chunk then
runScriptCacheStats.compileFailures = runScriptCacheStats.compileFailures + 1
debug(1, "컴파일 오류: " .. (err or "알 수 없음"))
return nil
end
runScriptCacheStats.compiles = runScriptCacheStats.compiles + 1
end
-- compiled chunk는 context 사이에 공유해도 handler closure는 namespace마다 새로
-- 만든다. 모듈이 outer local cache를 가져도 다른 채팅과 mutable state가 섞이지 않는다.
local ok, handler = pcall(chunk)
if not ok then
runScriptCacheStats.loadFailures = runScriptCacheStats.loadFailures + 1
debug(1, "스크립트 로드 오류: " .. tostring(handler))
if entry ~= nil then
bucket[source] = nil
runScriptSourceCacheSize = runScriptSourceCacheSize - 1
end
return nil
end
if type(handler) ~= "function" then
runScriptCacheStats.invalidHandlers = runScriptCacheStats.invalidHandlers + 1
debug(1, "runScript error: lorebook '" .. script .. "' did not return a function.")
if entry ~= nil then
bucket[source] = nil
runScriptSourceCacheSize = runScriptSourceCacheSize - 1
end
return nil
end
if entry == nil then
evictOldestRunScriptSourceEntry()
bucket = runScriptSourceBuckets[script]
if bucket == nil then
bucket = {}
runScriptSourceBuckets[script] = bucket
end
entry = {
chunk = chunk,
sourceLength = #source,
sourceFingerprint = fingerprintRunScriptIdentity(source),
hits = 0,
}
touchRunScriptCacheEntry(entry)
bucket[source] = entry
runScriptSourceCacheSize = runScriptSourceCacheSize + 1
end
return handler, entry.sourceFingerprint
end
local function evictOldestRunScriptWarmEntry()
if runScriptWarmCacheSize < RUN_SCRIPT_WARM_CACHE_MAX_ENTRIES then
return
end
local oldestKey = nil
local oldestEntry = nil
for key, entry in pairs(runScriptWarmCache) do
if oldestEntry == nil or entry.lastUsed < oldestEntry.lastUsed then
oldestKey = key
oldestEntry = entry
end
end
if oldestEntry ~= nil then
runScriptWarmCache[oldestKey] = nil
runScriptWarmCacheSize = runScriptWarmCacheSize - 1
runScriptCacheStats.warmEvictions = runScriptCacheStats.warmEvictions + 1
end
end
function setRunScriptCacheDevelopmentMode(enabled)
local nextValue = enabled == true
if RUNTIME_CACHE_DEVELOPMENT_BYPASS ~= nextValue then
RUNTIME_CACHE_DEVELOPMENT_BYPASS = nextValue
-- dev에서 관측한 source와 기존 production warm handler가 엇갈리지 않도록
-- 모드 전환 시 warm layer만 폐기한다. compiled source cache는 안전하게 재사용한다.
runScriptWarmCache = {}
runScriptWarmCacheSize = 0
runScriptCacheEpoch = runScriptCacheEpoch + 1
activeRuntimeEvent = nil
runScriptCacheStats.developmentModeChanges = runScriptCacheStats.developmentModeChanges + 1
end
return RUNTIME_CACHE_DEVELOPMENT_BYPASS
end
-- 개발 중 로어 교체나 진단 도구에서 명시적으로 캐시를 비울 수 있다.
-- script를 생략하면 전체를, 전달하면 해당 모듈의 compiled/warm entry를 제거한다.
function clearRunScriptCache(script)
if script ~= nil and type(script) ~= "string" then
return 0
end
local removedSources = 0
if script == nil then
removedSources = runScriptSourceCacheSize
runScriptSourceBuckets = {}
runScriptSourceCacheSize = 0
runScriptWarmCache = {}
runScriptWarmCacheSize = 0
else
local bucket = runScriptSourceBuckets[script]
if bucket ~= nil then
for _ in pairs(bucket) do
removedSources = removedSources + 1
end
runScriptSourceBuckets[script] = nil
runScriptSourceCacheSize = runScriptSourceCacheSize - removedSources
end
for key, entry in pairs(runScriptWarmCache) do
if entry.script == script then
runScriptWarmCache[key] = nil
runScriptWarmCacheSize = runScriptWarmCacheSize - 1
end
end
end
runScriptCacheEpoch = runScriptCacheEpoch + 1
activeRuntimeEvent = nil
runScriptCacheStats.clears = runScriptCacheStats.clears + 1
return removedSources
end
local turnInitializationDepth = 0
local function policyIsFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function policyIsInteger(value, minimum)
return policyIsFinite(value)
and value % 1 == 0
and (minimum == nil or value >= minimum)
end
local function copyPolicyTable(value)
local copy = {}
for key, item in pairs(value) do
copy[key] = item
end
return copy
end
-- 완료 이력은 직전 턴의 finish를 기록한다. 다음 턴 초기화 중에는
-- turn_start 효과가 현재 은폐·저항·무드를 합법적으로 바꿀 수 있으므로,
-- battleHistory의 연속성 비교에만 직전 finish를 투영한다. 실제 state는
-- 바꾸지 않으며 stateSchema의 나머지 검증은 원본 state를 계속 검사한다.
local function projectHistoryValidationState(history, state)
if type(history) ~= "table"
or type(history.turns) ~= "table"
or type(state) ~= "table"
or state.status ~= "active"
or not policyIsInteger(state.turnNumber, 1) then
return state, false
end
local turnCount = #history.turns
if turnCount == 0 or state.turnNumber ~= turnCount + 1 then
return state, false
end
local last = history.turns[turnCount]
local finish = type(last) == "table" and last.finish or nil
if type(finish) ~= "table"
or state.lastCommittedTurnId ~= last.turnId
or not policyIsFinite(finish.stealth)
or not policyIsFinite(finish.resistance)
or type(finish.mood) ~= "string"
or finish.status ~= "active"
or type(state.player) ~= "table"
or type(state.character) ~= "table" then
return state, false
end
local receipt = state.turnStartReceipt
if receipt ~= nil then
local baseline = type(receipt) == "table" and receipt.baseline or nil
if type(baseline) ~= "table"
or baseline.stealth ~= finish.stealth
or baseline.resistance ~= finish.resistance
or baseline.mood ~= finish.mood then
return state, false
end
end
local projected = copyPolicyTable(state)
projected.player = copyPolicyTable(state.player)
projected.character = copyPolicyTable(state.character)
projected.player.stealth = finish.stealth
projected.character.resistance = finish.resistance
projected.character.mood = finish.mood
return projected, true
for scriptName, bucket in pairs(runScriptSourceBuckets) do
for _, entry in pairs(bucket) do
table.insert(diagnostics.sources, {
script = scriptName,
sourceLength = entry.sourceLength,
sourceFingerprint = entry.sourceFingerprint,
hits = entry.hits,
lastUsed = entry.lastUsed,
})
end
end
for _, entry in pairs(runScriptWarmCache) do
table.insert(diagnostics.warm, {
script = entry.script,
bundleRevision = entry.bundleRevision,
mode = entry.mode,
namespaceFingerprint = entry.namespaceFingerprint,
characterIdentity = entry.characterIdentity,
sourceFingerprint = entry.sourceFingerprint,
hits = entry.hits,
lastUsed = entry.lastUsed,
})
end
local function sortDiagnostics(left, right)
if left.script == right.script then
return (left.sourceFingerprint or "") < (right.sourceFingerprint or "")
end
return left.script < right.script
end
table.sort(diagnostics.sources, sortDiagnostics)
table.sort(diagnostics.warm, sortDiagnostics)
return diagnostics
end
--로어북에서 스크립트를 호출해 실행
local function invokeRunScriptHandler(handler, triggerId, ...)
local packed = table.pack(pcall(handler, triggerId, ...))
if not packed[1] then
runScriptCacheStats.executionFailures = runScriptCacheStats.executionFailures + 1
debug(1, "스크립트 실행 오류: " .. tostring(packed[2]))
return nil
end
return table.unpack(packed, 2, packed.n)
end
local function invokeRunScriptWithPolicy(handler, triggerId, script, ...)
local arguments = table.pack(...)
local action = arguments[1]
local isTurnInitialization = script == "turnInitializer"
and action == "prepareTurn"
if isTurnInitialization then
turnInitializationDepth = turnInitializationDepth + 1
end
local packed = table.pack(pcall(function()
if turnInitializationDepth > 0
and script == "battleHistory"
and action == "validate" then
local projected, applied = projectHistoryValidationState(
arguments[2],
arguments[3]
)
if applied then
arguments[3] = projected
end
end
return invokeRunScriptHandler(
handler,
triggerId,
table.unpack(arguments, 1, arguments.n)
)
end))
if isTurnInitialization then
turnInitializationDepth = turnInitializationDepth - 1
end
if not packed[1] then
debug(1, "runtime policy execution error: " .. tostring(packed[2]))
return nil
end
return table.unpack(packed, 2, packed.n)
end
function runScript(triggerId, script, ...)
runScriptCacheStats.requests = runScriptCacheStats.requests + 1
local event = ensureRunScriptEvent(triggerId)
local transactionHandler = event.handlers[script]
if transactionHandler ~= nil then
runScriptCacheStats.transactionHits = runScriptCacheStats.transactionHits + 1
return invokeRunScriptWithPolicy(transactionHandler, triggerId, script, ...)
end
local handler = nil
local sourceFingerprint = nil
local warmKey = makeRunScriptWarmKey(event, script)
if not RUNTIME_CACHE_DEVELOPMENT_BYPASS then
local warmEntry = runScriptWarmCache[warmKey]
if warmEntry ~= nil then
runScriptCacheStats.warmHits = runScriptCacheStats.warmHits + 1
warmEntry.hits = warmEntry.hits + 1
touchRunScriptCacheEntry(warmEntry)
handler = warmEntry.handler
else
runScriptCacheStats.warmMisses = runScriptCacheStats.warmMisses + 1
end
end
if handler == nil then
runScriptCacheStats.sourceFetches = runScriptCacheStats.sourceFetches + 1
local source = loadScriptLore(triggerId, script)
if not source then
runScriptCacheStats.missingSources = runScriptCacheStats.missingSources + 1
debug(1, "runScript error: lorebook '" .. script .. "' not found.")
return nil
end
handler, sourceFingerprint = instantiateRunScriptHandler(script, source)
if handler == nil then
return nil
end
if not RUNTIME_CACHE_DEVELOPMENT_BYPASS then
storeRunScriptWarmHandler(warmKey, event, script, handler, sourceFingerprint)
end
end
if event.handlerCount < RUN_SCRIPT_TRANSACTION_MAX_ENTRIES then
event.handlers[script] = handler
event.handlerCount = event.handlerCount + 1
else
runScriptCacheStats.transactionCapacitySkips = runScriptCacheStats.transactionCapacitySkips + 1
end
return invokeRunScriptWithPolicy(handler, triggerId, script, ...)
end
--로어북에서 db를 호출해 return
function loadDB(triggerId, db)
local lores = getLoreBooks(triggerId, db)
if #lores == 0 then
debug(1, "loadDB error: lorebook '" .. db .. "' not found.")
return nil
end
local merged = {}
local loadedCount = 0
for _, lore in ipairs(lores) do
local ok, decoded = pcall(json.decode, lore.content)
if not ok or decoded == nil then
debug(1, "loadDB error: invalid json in lorebook '" .. db .. "'.")
return nil
end
if type(decoded) ~= "table" then
return decoded
end
loadedCount = loadedCount + 1
for k, v in pairs(decoded) do
if type(k) == "number" then
table.insert(merged, v)
else
merged[k] = v
end
end
end
if loadedCount == 0 then
return nil
end
return merged
end
--변수를 로어북으로 저장
function saveDB(triggerId, name, data, ...)
if data == nil then
debug(1, "saveDB error: data is nil.")
return
end
upsertLocalLoreBook(triggerId, name, json.encode(data), ...)
debug(2, "saveDB: "..name.." saved successfully.")
debug(3, name, json.encode(data))
end
--구분자로 문자열을 나누어 return
function splitByDelimiter(text, delimiter)
local result = {}
local source = tostring(text or "")
local startIndex = 1
while true do
local foundStart, foundEnd = string.find(source, delimiter, startIndex, true)
if not foundStart then
table.insert(result, string.sub(source, startIndex))
break
end
table.insert(result, string.sub(source, startIndex, foundStart - 1))
startIndex = foundEnd + 1
end
return result
end
return function(triggerId, action, ...)
if action == "install" then
return true
elseif action == "beginEvent" then
return beginRunScriptEvent(triggerId, ...)
elseif action == "run" then
return runScript(triggerId, ...)
elseif action == "diagnostics" then
return getRunScriptCacheDiagnostics()
elseif action == "clearCache" then
return clearRunScriptCache(...)
elseif action == "setDevelopmentMode" then
return setRunScriptCacheDevelopmentMode(...)
end
error("unsupported runtime action: " .. tostring(action))
end
end)()
#47stateSchema.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local MAX_SAFE_INTEGER = 9007199254740991
local MAX_PLAN_CAPACITY = 16
local CHARACTER_SCORE_SCALE = 3
local FINGERPRINT_ALGORITHM = "canonical_poly131_137_receipt_v2"
local DRAFT_FINGERPRINT_ALGORITHM = "canonical_poly131_137_v1"
local PENDING_INTEGRITY_ALGORITHM = "canonical_poly131_137_pending_v1"
local VALID_STATUS = {
active = true,
victory = true,
defeat = true,
}
local VALID_OWNER = {
player = true,
character = true,
}
local VALID_ZONE = {
deck = true,
hand = true,
used = true,
discard = true,
removed = true,
plan = true,
}
local VALID_SCENE_WEEKDAYS = {
["월요일"] = true, ["화요일"] = true, ["수요일"] = true, ["목요일"] = true,
["금요일"] = true, ["토요일"] = true, ["일요일"] = true,
}
local VALID_SCENE_TIMES = {
["아침 출근시간"] = true, ["저녁 퇴근시간"] = true,
["아침 시간"] = true, ["저녁 시간"] = true,
["한낮"] = true, ["심야열차"] = true,
}
local VALID_SCENE_WEATHERS = {
["맑음"] = true, ["흐림"] = true, ["가벼운 비"] = true, ["거센 비"] = true,
}
local function addError(errors, code, path, message)
table.insert(errors, {
code = code,
path = path,
message = message,
})
end
local function result(errors, value)
return {
ok = #errors == 0,
schemaVersion = SCHEMA_VERSION,
errors = errors,
value = #errors == 0 and value or nil,
}
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and (minimum == nil or value >= minimum)
end
local function isSafeInteger(value, minimum)
return isInteger(value, minimum)
and math.abs(value) <= MAX_SAFE_INTEGER
end
local function isAsciiId(value)
return type(value) == "string"
and string.match(value, "^[a-z][a-z0-9_]*$") ~= nil
end
local function isRuntimeId(value)
return type(value) == "string"
and string.match(value, "^[A-Za-z0-9][A-Za-z0-9_-]*$") ~= nil
end
local function objectPath(path, key)
if type(key) == "string" and string.match(key, "^[A-Za-z_][A-Za-z0-9_]*$") then
return path .. "." .. key
end
return path .. "[" .. string.format("%q", tostring(key)) .. "]"
end
local function inspectTable(value, path, errors)
if type(value) ~= "table" then
addError(errors, "expected_table", path, "테이블이어야 합니다.")
return nil
end
local numericCount = 0
local maximum = 0
local hasNumeric = false
local hasString = false
local stringKeys = {}
for key in pairs(value) do
if type(key) == "number" then
hasNumeric = true
numericCount = numericCount + 1
if not isInteger(key, 1) then
addError(errors, "invalid_array_index", path, "배열 인덱스는 1 이상의 정수여야 합니다.")
return nil
end
if key > maximum then
maximum = key
end
elseif type(key) == "string" then
hasString = true
table.insert(stringKeys, key)
else
addError(errors, "invalid_object_key", path, "객체 키는 문자열이어야 합니다.")
return nil
end
end
if hasNumeric and hasString then
addError(errors, "mixed_table", path, "숫자 인덱스와 문자열 키를 함께 사용할 수 없습니다.")
return nil
end
if hasNumeric then
if numericCount ~= maximum then
addError(errors, "sparse_array", path, "배열 인덱스는 1부터 빈틈없이 이어져야 합니다.")
return nil
end
return "array", maximum
end
if hasString then
table.sort(stringKeys)
return "object", stringKeys
end
return "array", 0
end
local function validateJsonSafe(value, path, errors, active)
local valueType = type(value)
if valueType == "string" or valueType == "boolean" then
return
end
if valueType == "number" then
if not isFinite(value) then
addError(errors, "non_finite_number", path, "NaN과 무한대는 저장할 수 없습니다.")
end
return
end
if valueType ~= "table" then
addError(errors, "unsupported_type", path, "JSON 상태에 저장할 수 없는 자료형입니다: " .. valueType)
return
end
if getmetatable(value) ~= nil then
addError(errors, "metatable_not_allowed", path, "상태 테이블에는 메타테이블을 사용할 수 없습니다.")
return
end
active = active or {}
if active[value] then
addError(errors, "circular_reference", path, "순환 참조가 있는 테이블은 저장할 수 없습니다.")
return
end
active[value] = true
local kind, shape = inspectTable(value, path, errors)
if kind == "array" then
for index = 1, shape do
validateJsonSafe(value[index], path .. "[" .. index .. "]", errors, active)
end
elseif kind == "object" then
for _, key in ipairs(shape) do
validateJsonSafe(value[key], objectPath(path, key), errors, active)
end
end
active[value] = nil
end
local function checkAllowedKeys(value, allowed, path, errors)
if type(value) ~= "table" then
return
end
for key in pairs(value) do
if type(key) ~= "string" or not allowed[key] then
addError(errors, "unknown_field", objectPath(path, key), "허용되지 않은 필드입니다.")
end
end
end
local function getArrayLength(value, path, errors)
local kind, length = inspectTable(value, path, errors)
if kind ~= "array" then
if kind ~= nil then
addError(errors, "expected_array", path, "연속 배열이어야 합니다.")
end
return nil
end
return length
end
local function validateIdArray(value, path, errors, idValidator)
local length = getArrayLength(value, path, errors)
if length == nil then
return
end
local seen = {}
for index = 1, length do
local item = value[index]
if not idValidator(item) then
addError(errors, "invalid_id", path .. "[" .. index .. "]", "올바른 ID 문자열이 아닙니다.")
else
if seen[item] then
addError(errors, "duplicate_id", path .. "[" .. index .. "]", "같은 ID가 중복되었습니다: " .. item)
end
seen[item] = true
end
end
end
local function normalizeStaticData(staticData)
if type(staticData) ~= "table" or getmetatable(staticData) ~= nil then
return staticData
end
local data = rawget(staticData, "data")
if type(data) == "table" and getmetatable(data) == nil then
return data
end
return staticData
end
local function isPlainTableTree(value, active, seen)
if type(value) ~= "table" then
return true
end
if getmetatable(value) ~= nil then
return false
end
active = active or {}
seen = seen or {}
if active[value] then
return false
end
if seen[value] then
return true
end
active[value] = true
for key, item in pairs(value) do
if not isPlainTableTree(key, active, seen)
or not isPlainTableTree(item, active, seen) then
active[value] = nil
return false
end
end
active[value] = nil
seen[value] = true
return true
end
local function fingerprintFailure(code, path, message)
error({
code = code,
path = path,
message = message,
}, 0)
end
local function inspectCanonicalTable(value, path)
if type(value) ~= "table" or getmetatable(value) ~= nil then
fingerprintFailure("invalid_table", path, "fingerprint 대상은 일반 테이블이어야 합니다.")
end
local numericCount = 0
local maximum = 0
local hasNumeric = false
local hasString = false
local stringKeys = {}
for key in pairs(value) do
if type(key) == "number" then
hasNumeric = true
numericCount = numericCount + 1
if not isInteger(key, 1) then
fingerprintFailure("invalid_array_index", path, "배열 인덱스는 1 이상의 정수여야 합니다.")
end
if key > maximum then
maximum = key
end
elseif type(key) == "string" then
hasString = true
table.insert(stringKeys, key)
else
fingerprintFailure("invalid_object_key", path, "객체 키는 문자열이어야 합니다.")
end
end
if hasNumeric and hasString then
fingerprintFailure("mixed_table", path, "숫자 인덱스와 문자열 키를 함께 사용할 수 없습니다.")
end
if hasNumeric and numericCount ~= maximum then
fingerprintFailure("sparse_array", path, "배열 인덱스는 1부터 빈틈없이 이어져야 합니다.")
end
table.sort(stringKeys)
return hasNumeric, maximum, stringKeys
end
local function canonicalJson(value, path, active)
local valueType = type(value)
if valueType == "nil" then
return "n"
end
if valueType == "boolean" then
return value and "t" or "f"
end
if valueType == "number" then
if not isFinite(value) then
fingerprintFailure("non_finite_number", path, "NaN과 무한대는 fingerprint에 사용할 수 없습니다.")
end
return "d" .. string.format("%.17g", value) .. ";"
end
if valueType == "string" then
return "s" .. tostring(#value) .. ":" .. value
end
if valueType ~= "table" then
fingerprintFailure("unsupported_type", path, "fingerprint에 사용할 수 없는 자료형입니다: " .. valueType)
end
active = active or {}
if active[value] then
fingerprintFailure("circular_reference", path, "순환 참조가 있는 값은 fingerprint에 사용할 수 없습니다.")
end
active[value] = true
local isArray, length, stringKeys = inspectCanonicalTable(value, path)
local parts = {}
if isArray then
parts[1] = "["
for index = 1, length do
parts[#parts + 1] = canonicalJson(value[index], path .. "[" .. index .. "]", active)
end
parts[#parts + 1] = "]"
else
parts[1] = "{"
for _, key in ipairs(stringKeys) do
parts[#parts + 1] = "k" .. tostring(#key) .. ":" .. key
parts[#parts + 1] = canonicalJson(value[key], objectPath(path, key), active)
end
parts[#parts + 1] = "}"
end
active[value] = nil
return table.concat(parts)
end
local function fingerprintAuthorityState(state)
local authorityState = {}
for key, value in pairs(state) do
if key == "turnStartReceipt" and type(value) == "table" then
local receipt = {}
for receiptKey, receiptValue in pairs(value) do
if receiptKey ~= "authorityFingerprint" then
receipt[receiptKey] = receiptValue
end
end
authorityState[key] = receipt
else
authorityState[key] = value
end
end
local ok, canonical = pcall(canonicalJson, authorityState, "$", {})
if not ok then
if type(canonical) == "table" and canonical.code and canonical.path and canonical.message then
return nil, canonical
end
return nil, {
code = "fingerprint_failed",
path = "$",
message = "전투 상태 fingerprint 생성에 실패했습니다: " .. tostring(canonical),
}
end
local hashA = 0
local hashB = 0
for index = 1, #canonical do
local byte = string.byte(canonical, index)
hashA = (hashA * 131 + byte) % 2147483647
hashB = (hashB * 137 + byte) % 2147483629
end
return {
algorithm = FINGERPRINT_ALGORITHM,
length = #canonical,
hashA = hashA,
hashB = hashB,
}, nil
end
local function fingerprintDraftAuthorityState(state)
local ok, canonical = pcall(canonicalJson, state, "$", {})
if not ok then
if type(canonical) == "table" and canonical.code and canonical.path and canonical.message then
return nil, canonical
end
return nil, {
code = "fingerprint_failed",
path = "$",
message = "드래프트 권위 상태 fingerprint 생성에 실패했습니다: " .. tostring(canonical),
}
end
local hashA = 0
local hashB = 0
for index = 1, #canonical do
local byte = string.byte(canonical, index)
hashA = (hashA * 131 + byte) % 2147483647
hashB = (hashB * 137 + byte) % 2147483629
end
return {
algorithm = DRAFT_FINGERPRINT_ALGORITHM,
length = #canonical,
hashA = hashA,
hashB = hashB,
}, nil
end
local function fingerprintPendingTurn(pending)
local payload = {}
for key, value in pairs(pending) do
if key ~= "integrity" then
payload[key] = value
end
end
local ok, canonical = pcall(canonicalJson, payload, "$", {})
if not ok then
if type(canonical) == "table" and canonical.code and canonical.path and canonical.message then
return nil, canonical
end
return nil, {
code = "fingerprint_failed",
path = "$.integrity",
message = "pendingTurn 무결성 fingerprint 생성에 실패했습니다: " .. tostring(canonical),
}
end
local hashA = 0
local hashB = 0
for index = 1, #canonical do
local byte = string.byte(canonical, index)
hashA = (hashA * 131 + byte) % 2147483647
hashB = (hashB * 137 + byte) % 2147483629
end
return {
algorithm = PENDING_INTEGRITY_ALGORITHM,
length = #canonical,
hashA = hashA,
hashB = hashB,
}, nil
end
local function hasCompleteStaticData(staticData)
return type(staticData) == "table"
and getmetatable(staticData) == nil
and type(rawget(staticData, "registry")) == "table"
and type(rawget(staticData, "cards")) == "table"
and type(rawget(staticData, "traits")) == "table"
and type(rawget(staticData, "subwayLines")) == "table"
and type(rawget(staticData, "characters")) == "table"
and isPlainTableTree(staticData)
end
local function appendNestedErrors(target, prefix, nested)
if type(nested) ~= "table" or type(nested.errors) ~= "table" then
addError(target, "validation_failed", prefix, "하위 스키마 검증 결과가 올바르지 않습니다.")
return
end
for _, item in ipairs(nested.errors) do
local suffix = tostring(item.path or "$")
if string.sub(suffix, 1, 1) == "$" then
suffix = string.sub(suffix, 2)
end
addError(
target,
tostring(item.code or "validation_failed"),
prefix .. suffix,
tostring(item.message or "하위 스키마 검증에 실패했습니다.")
)
end
end
local function validatePlanSlot(slot, side, path, errors, instances, staticData, currentTurn)
if type(slot) ~= "table" then
addError(errors, "invalid_plan_slot", path, "계획 슬롯이 테이블이 아닙니다.")
return
end
local occupied = slot.occupied
checkAllowedKeys(slot, {
occupied = true,
cardInstanceId = true,
cardId = true,
placedTurn = true,
durationIncludesPlacementTurn = true,
remainingTurns = true,
remainingCharges = true,
revealed = true,
effectChoiceId = true,
}, path, errors)
if occupied ~= true then
addError(errors, "invalid_plan_occupied", path .. ".occupied", "planSlots에는 occupied = true인 점유 슬롯만 저장할 수 있습니다.")
return
end
if not isRuntimeId(slot.cardInstanceId) then
addError(errors, "invalid_instance_id", path .. ".cardInstanceId", "계획 카드 인스턴스 ID가 올바르지 않습니다.")
end
if not isAsciiId(slot.cardId) then
addError(errors, "invalid_card_id", path .. ".cardId", "계획 카드 ID가 올바르지 않습니다.")
end
if not isInteger(slot.placedTurn, 1) then
addError(errors, "invalid_placed_turn", path .. ".placedTurn", "배치 턴은 1 이상의 정수여야 합니다.")
elseif isInteger(currentTurn, 1) and slot.placedTurn > currentTurn then
addError(errors, "future_placed_turn", path .. ".placedTurn", "계획 배치 턴은 현재 턴보다 클 수 없습니다.")
end
if slot.revealed ~= true and slot.revealed ~= false then
addError(errors, "invalid_revealed", path .. ".revealed", "revealed는 불리언이어야 합니다.")
end
if slot.effectChoiceId ~= nil and not isAsciiId(slot.effectChoiceId) then
addError(errors, "invalid_effect_choice_id", path .. ".effectChoiceId", "계획 효과 선택지 ID가 올바르지 않습니다.")
end
if slot.durationIncludesPlacementTurn ~= nil
and type(slot.durationIncludesPlacementTurn) ~= "boolean" then
addError(
errors,
"invalid_plan_duration_policy",
path .. ".durationIncludesPlacementTurn",
"배치 턴 포함 여부는 불리언이어야 합니다."
)
elseif slot.durationIncludesPlacementTurn == true and slot.remainingTurns == nil then
addError(
errors,
"plan_duration_policy_requires_duration",
path .. ".durationIncludesPlacementTurn",
"배치 턴을 포함하는 계획에는 남은 지속시간이 필요합니다."
)
end
local hasLifetime = false
if slot.remainingTurns ~= nil then
hasLifetime = true
if not isInteger(slot.remainingTurns, 1) then
addError(errors, "invalid_remaining_turns", path .. ".remainingTurns", "점유된 계획의 남은 지속 턴은 1 이상의 정수여야 합니다.")
end
end
if slot.remainingCharges ~= nil then
hasLifetime = true
if not isInteger(slot.remainingCharges, 1) then
addError(errors, "invalid_remaining_charges", path .. ".remainingCharges", "점유된 계획의 남은 충전은 1 이상의 정수여야 합니다.")
end
end
if not hasLifetime then
addError(errors, "missing_plan_lifetime", path, "점유된 계획에는 제한된 수명이 필요합니다.")
end
local instance = instances[slot.cardInstanceId]
if not instance then
addError(errors, "missing_plan_instance", path .. ".cardInstanceId", "계획 카드 인스턴스를 찾을 수 없습니다.")
else
if instance.owner ~= side then
addError(errors, "plan_owner_mismatch", path .. ".cardInstanceId", "계획 슬롯과 카드 소유자가 다릅니다.")
end
if instance.zone ~= "plan" then
addError(errors, "plan_zone_mismatch", path .. ".cardInstanceId", "계획 카드 인스턴스의 zone이 plan이 아닙니다.")
end
if instance.cardId ~= slot.cardId then
addError(errors, "plan_card_mismatch", path .. ".cardId", "계획 슬롯과 카드 인스턴스의 카드 ID가 다릅니다.")
end
end
local cards = type(staticData) == "table" and staticData.cards or nil
local card = type(cards) == "table" and cards[slot.cardId] or nil
if card and card.cardType ~= "plan" then
addError(errors, "plan_type_mismatch", path .. ".cardId", "계획 슬롯의 카드 유형이 plan이 아닙니다.")
elseif card then
local matchedChoice = nil
for _, choice in ipairs(type(card.effectChoices) == "table" and card.effectChoices or {}) do
if choice.id == slot.effectChoiceId then matchedChoice = choice end
end
if type(card.effectChoices) == "table" and matchedChoice == nil then
addError(errors, "missing_plan_effect_choice", path .. ".effectChoiceId", "선택형 계획에 유효한 효과 선택값이 없습니다.")
elseif matchedChoice ~= nil and matchedChoice.placesPlan == false then
addError(errors, "invalid_plan_effect_choice", path .. ".effectChoiceId", "즉시 발동 선택지는 계획 슬롯에 저장할 수 없습니다.")
elseif type(card.effectChoices) ~= "table" and slot.effectChoiceId ~= nil then
addError(errors, "unexpected_plan_effect_choice", path .. ".effectChoiceId", "선택지가 없는 계획에 효과 선택값이 있습니다.")
end
local planData = type(card.mechanismData) == "table" and card.mechanismData.plan or nil
local expectedIncludesPlacementTurn = type(planData) == "table"
and planData.durationIncludesPlacementTurn == true
if (slot.durationIncludesPlacementTurn == true) ~= expectedIncludesPlacementTurn then
addError(
errors,
"plan_duration_policy_mismatch",
path .. ".durationIncludesPlacementTurn",
"계획 슬롯의 배치 턴 포함 정책이 정적 카드 정의와 다릅니다."
)
end
local initialDurationTurns = type(planData) == "table" and planData.durationTurns or nil
if initialDurationTurns ~= nil and slot.remainingTurns == nil then
addError(
errors,
"plan_remaining_turns_missing",
path .. ".remainingTurns",
"정적 카드 정의에 지속시간이 있는 활성 계획에는 remainingTurns가 필요합니다."
)
elseif slot.remainingTurns ~= nil then
if initialDurationTurns == nil then
addError(
errors,
"plan_remaining_turns_undefined",
path .. ".remainingTurns",
"정적 카드 정의에 지속시간이 없는 계획은 remainingTurns를 가질 수 없습니다."
)
elseif isInteger(slot.remainingTurns, 1)
and isInteger(initialDurationTurns, 1)
and slot.remainingTurns > initialDurationTurns then
addError(
errors,
"plan_remaining_turns_exceeded",
path .. ".remainingTurns",
"계획의 남은 지속시간은 정적 카드 정의의 초기 지속시간을 초과할 수 없습니다."
)
end
end
local initialCharges = type(planData) == "table" and planData.charges or nil
if initialCharges ~= nil and slot.remainingCharges == nil then
addError(
errors,
"plan_remaining_charges_missing",
path .. ".remainingCharges",
"정적 카드 정의에 충전이 있는 활성 계획에는 remainingCharges가 필요합니다."
)
elseif slot.remainingCharges ~= nil then
if initialCharges == nil then
addError(
errors,
"plan_remaining_charges_undefined",
path .. ".remainingCharges",
"정적 카드 정의에 충전이 없는 계획은 remainingCharges를 가질 수 없습니다."
)
elseif isInteger(slot.remainingCharges, 1)
and isInteger(initialCharges, 1)
and slot.remainingCharges > initialCharges then
addError(
errors,
"plan_remaining_charges_exceeded",
path .. ".remainingCharges",
"계획의 남은 충전은 정적 카드 정의의 초기 충전을 초과할 수 없습니다."
)
end
end
end
end
local function validatePlanSlots(
slots,
capacity,
side,
path,
errors,
instances,
staticData,
currentTurn
)
local capacityPath = "$." .. side .. ".planCapacity"
local capacityValid = isSafeInteger(capacity, 1) and capacity <= MAX_PLAN_CAPACITY
if not capacityValid then
addError(errors, "invalid_plan_capacity", capacityPath, "계획 용량은 1 이상 16 이하의 정수여야 합니다.")
end
local slotCount = getArrayLength(slots, path, errors)
if slotCount == nil then
return
end
if capacityValid and slotCount > capacity then
addError(errors, "plan_capacity_exceeded", path, "점유 계획 수가 계획 용량을 초과했습니다.")
end
local seenInstanceIds = {}
for index = 1, slotCount do
local slot = slots[index]
local slotPath = path .. "[" .. index .. "]"
validatePlanSlot(slot, side, slotPath, errors, instances, staticData, currentTurn)
if type(slot) == "table" and isRuntimeId(slot.cardInstanceId) then
if seenInstanceIds[slot.cardInstanceId] then
addError(
errors,
"duplicate_plan_slot",
slotPath .. ".cardInstanceId",
"같은 계획 카드 인스턴스를 둘 이상의 슬롯에 저장할 수 없습니다."
)
else
seenInstanceIds[slot.cardInstanceId] = true
end
local instance = instances[slot.cardInstanceId]
if instance and instance.owner == side and instance.zone == "plan"
and instance.position ~= index then
addError(
errors,
"plan_position_mismatch",
slotPath .. ".cardInstanceId",
"plan 영역 position은 planSlots 배열 인덱스와 일치해야 합니다."
)
end
end
end
end
local function validateTurnStartEventSource(value, path, errors)
if type(value) ~= "table" then
addError(errors, "invalid_event_source", path, "턴 시작 사건의 source가 객체가 아닙니다.")
return
end
checkAllowedKeys(value, {
kind = true,
id = true,
side = true,
instanceId = true,
}, path, errors)
if not isAsciiId(value.kind) then
addError(errors, "invalid_event_source", path .. ".kind", "사건 source.kind는 lower_snake_case여야 합니다.")
end
if not isAsciiId(value.id) then
addError(errors, "invalid_event_source", path .. ".id", "사건 source.id는 lower_snake_case여야 합니다.")
end
if value.side ~= nil and not VALID_OWNER[value.side] then
addError(errors, "invalid_event_side", path .. ".side", "사건 source.side는 player 또는 character여야 합니다.")
end
if value.instanceId ~= nil and not isRuntimeId(value.instanceId) then
addError(errors, "invalid_instance_id", path .. ".instanceId", "사건 source.instanceId가 올바르지 않습니다.")
end
end
local function validateTurnStartEventCause(value, path, errors)
if type(value) ~= "table" then
addError(errors, "invalid_event_cause", path, "턴 시작 사건의 cause가 객체가 아닙니다.")
return
end
checkAllowedKeys(value, {
kind = true,
resolutionId = true,
eventId = true,
}, path, errors)
if not isAsciiId(value.kind) then
addError(errors, "invalid_event_cause", path .. ".kind", "사건 cause.kind는 lower_snake_case여야 합니다.")
end
if value.resolutionId ~= nil and not isRuntimeId(value.resolutionId) then
addError(errors, "invalid_resolution_id", path .. ".resolutionId", "사건 cause.resolutionId가 올바르지 않습니다.")
end
if value.eventId ~= nil and not isRuntimeId(value.eventId) then
addError(errors, "invalid_event_id", path .. ".eventId", "사건 cause.eventId가 올바르지 않습니다.")
end
end
local function validateTurnStartEvents(events, turnId, path, errors)
local eventCount = getArrayLength(events, path, errors)
if eventCount == nil then
return
end
for index = 1, eventCount do
local event = events[index]
local eventPath = path .. "[" .. index .. "]"
if type(event) ~= "table" then
addError(errors, "invalid_turn_start_event", eventPath, "턴 시작 사건이 객체가 아닙니다.")
else
checkAllowedKeys(event, {
eventId = true,
sequence = true,
type = true,
phase = true,
resolutionId = true,
side = true,
source = true,
cause = true,
payload = true,
}, eventPath, errors)
if event.sequence ~= index then
addError(errors, "invalid_event_sequence", eventPath .. ".sequence", "턴 시작 사건 sequence는 배열 순서와 일치해야 합니다.")
end
if isRuntimeId(turnId) then
local expectedEventId = turnId .. "-event-" .. string.format("%03d", index)
if event.eventId ~= expectedEventId then
addError(errors, "invalid_event_id", eventPath .. ".eventId", "턴 시작 사건 ID가 turnId와 순번에 일치하지 않습니다.")
end
elseif not isRuntimeId(event.eventId) then
addError(errors, "invalid_event_id", eventPath .. ".eventId", "턴 시작 사건 ID가 올바르지 않습니다.")
end
if not isAsciiId(event.type) then
addError(errors, "invalid_event_type", eventPath .. ".type", "사건 type은 lower_snake_case여야 합니다.")
end
if event.phase ~= "turn_start" then
addError(errors, "invalid_event_phase", eventPath .. ".phase", "turnStartReceipt 사건 phase는 turn_start여야 합니다.")
end
if event.resolutionId ~= nil and not isRuntimeId(event.resolutionId) then
addError(errors, "invalid_resolution_id", eventPath .. ".resolutionId", "사건 resolutionId가 올바르지 않습니다.")
end
if event.side ~= nil and not VALID_OWNER[event.side] then
addError(errors, "invalid_event_side", eventPath .. ".side", "사건 side는 player 또는 character여야 합니다.")
end
validateTurnStartEventSource(event.source, eventPath .. ".source", errors)
if event.cause ~= nil then
validateTurnStartEventCause(event.cause, eventPath .. ".cause", errors)
end
if event.payload ~= nil then
local payloadKind = inspectTable(event.payload, eventPath .. ".payload", errors)
if payloadKind ~= "object" then
addError(errors, "invalid_event_payload", eventPath .. ".payload", "사건 payload는 비어 있지 않은 JSON 객체여야 합니다.")
end
end
end
end
end
local function rngEqual(left, right)
return type(left) == "table"
and type(right) == "table"
and left.seed == right.seed
and left.cursor == right.cursor
end
local function validateReceiptRng(value, path, errors)
if type(value) ~= "table" then
addError(errors, "invalid_receipt_rng", path, "영수증 RNG가 객체가 아닙니다.")
return false
end
checkAllowedKeys(value, {
seed = true,
cursor = true,
}, path, errors)
local valid = true
if not isSafeInteger(value.seed, 0) then
addError(errors, "invalid_receipt_rng", path .. ".seed", "영수증 RNG seed는 0 이상의 안전한 정수여야 합니다.")
valid = false
end
if not isSafeInteger(value.cursor, 0) then
addError(errors, "invalid_receipt_rng", path .. ".cursor", "영수증 RNG cursor는 0 이상의 안전한 정수여야 합니다.")
valid = false
end
return valid
end
local function validateAuthorityFingerprint(value, path, errors)
if type(value) ~= "table" then
addError(errors, "invalid_authority_fingerprint", path, "권위 상태 fingerprint가 객체가 아닙니다.")
return false
end
checkAllowedKeys(value, {
algorithm = true,
length = true,
hashA = true,
hashB = true,
}, path, errors)
local valid = true
if value.algorithm ~= FINGERPRINT_ALGORITHM then
addError(
errors,
"invalid_fingerprint_algorithm",
path .. ".algorithm",
"지원하지 않는 권위 상태 fingerprint 알고리즘입니다."
)
valid = false
end
for _, field in ipairs({ "length", "hashA", "hashB" }) do
if not isSafeInteger(value[field], 0) then
addError(
errors,
"invalid_authority_fingerprint",
path .. "." .. field,
"권위 상태 fingerprint 수치는 0 이상의 안전한 정수여야 합니다."
)
valid = false
end
end
return valid
end
local function validateDrawReport(report, side, state, path, errors)
if type(report) ~= "table" then
addError(errors, "invalid_draw_receipt", path, "기본 드로우 영수증이 객체가 아닙니다.")
return
end
checkAllowedKeys(report, {
requested = true,
drawnInstanceIds = true,
rngBefore = true,
rngAfter = true,
}, path, errors)
local ownerState = type(state) == "table" and state[side] or nil
local expectedRequested = type(ownerState) == "table" and ownerState.baseDrawCount or nil
if not isInteger(report.requested, 1) then
addError(errors, "invalid_draw_requested", path .. ".requested", "기본 드로우 요청 수는 1 이상의 정수여야 합니다.")
elseif isInteger(expectedRequested, 1) and report.requested ~= expectedRequested then
addError(
errors,
"receipt_draw_count_mismatch",
path .. ".requested",
"기본 드로우 요청 수가 현재 진영의 baseDrawCount와 다릅니다."
)
end
local drawnCount = getArrayLength(report.drawnInstanceIds, path .. ".drawnInstanceIds", errors)
if drawnCount ~= nil then
validateIdArray(report.drawnInstanceIds, path .. ".drawnInstanceIds", errors, isRuntimeId)
if isInteger(report.requested, 1) and drawnCount > report.requested then
addError(errors, "draw_count_exceeded", path .. ".drawnInstanceIds", "실제 드로우 수가 요청 수보다 많습니다.")
end
end
local beforeValid = validateReceiptRng(report.rngBefore, path .. ".rngBefore", errors)
local afterValid = validateReceiptRng(report.rngAfter, path .. ".rngAfter", errors)
if beforeValid and afterValid then
if report.rngAfter.seed ~= report.rngBefore.seed then
addError(errors, "receipt_rng_seed_changed", path .. ".rngAfter.seed", "드로우 중 RNG seed가 바뀔 수 없습니다.")
end
if report.rngAfter.cursor < report.rngBefore.cursor then
addError(errors, "receipt_rng_reversed", path .. ".rngAfter.cursor", "드로우 중 RNG cursor가 이전으로 돌아갈 수 없습니다.")
end
end
end
local function validateDraws(draws, state, path, errors)
if type(draws) ~= "table" then
addError(errors, "invalid_draw_receipts", path, "기본 드로우 영수증 묶음이 객체가 아닙니다.")
return
end
checkAllowedKeys(draws, {
player = true,
character = true,
}, path, errors)
validateDrawReport(draws.player, "player", state, path .. ".player", errors)
validateDrawReport(draws.character, "character", state, path .. ".character", errors)
local playerAfter = type(draws.player) == "table" and draws.player.rngAfter or nil
local characterBefore = type(draws.character) == "table" and draws.character.rngBefore or nil
if type(playerAfter) == "table"
and type(characterBefore) == "table"
and not rngEqual(playerAfter, characterBefore) then
addError(
errors,
"receipt_rng_discontinuity",
path .. ".character.rngBefore",
"캐릭터 드로우 RNG 시작점이 플레이어 드로우 종료점과 다릅니다."
)
end
end
local function validateCandidateTotals(totals, path, errors)
if type(totals) ~= "table" then
addError(errors, "invalid_selection_totals", path, "후보 효과 합계가 객체가 아닙니다.")
return
end
local fields = {
"recoverResistance",
"loseStealth",
"damageResistance",
"recoverStealth",
}
checkAllowedKeys(totals, {
recoverResistance = true,
loseStealth = true,
damageResistance = true,
recoverStealth = true,
}, path, errors)
for _, field in ipairs(fields) do
if not isFinite(totals[field]) or totals[field] < 0 then
addError(
errors,
"invalid_selection_total",
path .. "." .. field,
"후보 효과 합계는 0 이상의 유한한 숫자여야 합니다."
)
end
end
end
local function projectHistoryContext(history)
if type(runScript) ~= "function" then return nil end
local ok, report = pcall(runScript, triggerId, "battleHistory", "context", history)
if not ok or type(report) ~= "table" or report.ok ~= true or type(report.context) ~= "table" then
return nil
end
return report.context
end
local function validateHistoryContext(value, path, errors)
if type(runScript) ~= "function" then
addError(errors, "history_context_validation_unavailable", path, "전투 이력 컨텍스트 검증기를 찾을 수 없습니다.")
return
end
local ok, report = pcall(runScript, triggerId, "battleHistory", "validateContext", value)
if not ok or type(report) ~= "table" then
addError(errors, "history_context_validation_failed", path, "전투 이력 컨텍스트 검증 호출에 실패했습니다.")
elseif report.ok ~= true then
appendNestedErrors(errors, path, report)
end
end
local function validateSelectionContext(context, path, errors, staticData)
if type(context) ~= "table" then
addError(errors, "invalid_selection_context", path, "선택 시점 컨텍스트가 객체가 아닙니다.")
return nil
end
checkAllowedKeys(context, {
turnNumber = true,
player = true,
character = true,
characterHand = true,
history = true,
}, path, errors)
if not isInteger(context.turnNumber, 1) then
addError(errors, "invalid_turn_number", path .. ".turnNumber", "선택 시점 턴 번호는 1 이상의 정수여야 합니다.")
end
validateHistoryContext(context.history, path .. ".history", errors)
if type(context.player) ~= "table" then
addError(errors, "invalid_selection_context", path .. ".player", "선택 시점 플레이어 컨텍스트가 객체가 아닙니다.")
else
checkAllowedKeys(context.player, {
stealth = true,
handCount = true,
}, path .. ".player", errors)
if not isFinite(context.player.stealth) then
addError(errors, "invalid_selection_context", path .. ".player.stealth", "선택 시점 은폐가 유한한 숫자가 아닙니다.")
end
if not isSafeInteger(context.player.handCount, 0) then
addError(errors, "invalid_selection_context", path .. ".player.handCount", "선택 시점 플레이어 손패 수가 안전한 비음수 정수가 아닙니다.")
end
end
if type(context.character) ~= "table" then
addError(errors, "invalid_selection_context", path .. ".character", "선택 시점 캐릭터 컨텍스트가 객체가 아닙니다.")
else
checkAllowedKeys(context.character, {
resistance = true,
mood = true,
moodTokens = true,
}, path .. ".character", errors)
if not isFinite(context.character.resistance) then
addError(errors, "invalid_selection_context", path .. ".character.resistance", "선택 시점 저항이 유한한 숫자가 아닙니다.")
end
if not isAsciiId(context.character.mood) then
addError(errors, "invalid_selection_context", path .. ".character.mood", "선택 시점 무드 ID가 올바르지 않습니다.")
end
local moodTokens = context.character.moodTokens
local moods = type(staticData) == "table"
and type(staticData.registry) == "table"
and type(staticData.registry.moods) == "table"
and staticData.registry.moods
or nil
if type(moodTokens) ~= "table" or getmetatable(moodTokens) ~= nil then
addError(errors, "invalid_selection_context", path .. ".character.moodTokens", "선택 시점 무드 토큰이 객체가 아닙니다.")
else
for moodId in pairs(moods or {}) do
if moodTokens[moodId] == nil then
addError(errors, "invalid_mood_token_count", path .. ".character.moodTokens." .. moodId, "선택 시점 무드 토큰 수가 올바르지 않습니다.")
end
end
for moodId, count in pairs(moodTokens) do
if not isAsciiId(moodId) then
addError(errors, "invalid_mood", path .. ".character.moodTokens." .. tostring(moodId), "선택 시점 무드 토큰 ID가 올바르지 않습니다.")
elseif moods ~= nil and moods[moodId] == nil then
addError(errors, "unknown_mood", path .. ".character.moodTokens." .. tostring(moodId), "선택 시점에 등록되지 않은 무드 토큰이 있습니다.")
elseif not isSafeInteger(count, 0) then
addError(errors, "invalid_mood_token_count", path .. ".character.moodTokens." .. moodId, "선택 시점 무드 토큰 수가 올바르지 않습니다.")
end
end
end
end
local handCount = getArrayLength(context.characterHand, path .. ".characterHand", errors)
local seen = {}
if handCount ~= nil then
for index = 1, handCount do
local entry = context.characterHand[index]
local entryPath = path .. ".characterHand[" .. index .. "]"
if type(entry) ~= "table" then
addError(errors, "invalid_selection_hand_entry", entryPath, "선택 시점 손패 항목이 객체가 아닙니다.")
else
checkAllowedKeys(entry, {
instanceId = true,
cardId = true,
role = true,
handPosition = true,
}, entryPath, errors)
if not isRuntimeId(entry.instanceId) then
addError(errors, "invalid_instance_id", entryPath .. ".instanceId", "선택 시점 손패 인스턴스 ID가 올바르지 않습니다.")
elseif seen[entry.instanceId] then
addError(errors, "duplicate_id", entryPath .. ".instanceId", "선택 시점 손패 인스턴스 ID가 중복되었습니다.")
else
seen[entry.instanceId] = true
end
if not isAsciiId(entry.cardId) then
addError(errors, "invalid_card_id", entryPath .. ".cardId", "선택 시점 손패 카드 ID가 올바르지 않습니다.")
end
if not isAsciiId(entry.role) then
addError(errors, "invalid_role", entryPath .. ".role", "선택 시점 손패 역할이 올바르지 않습니다.")
end
if entry.handPosition ~= index then
addError(errors, "invalid_hand_position", entryPath .. ".handPosition", "선택 시점 캐릭터 손패 위치는 안정 순서의 1부터 연속되어야 합니다.")
end
end
end
end
return handCount
end
local function validateSelectionCandidate(candidate, path, errors)
if type(candidate) ~= "table" then
addError(errors, "invalid_selection_candidate", path, "캐릭터 선택 후보가 객체가 아닙니다.")
return
end
checkAllowedKeys(candidate, {
instanceId = true,
cardId = true,
role = true,
handPosition = true,
score = true,
projectedPlayerStealth = true,
lethal = true,
weight = true,
moodScore = true,
totals = true,
planChargesEvaluated = true,
}, path, errors)
if not isRuntimeId(candidate.instanceId) then
addError(errors, "invalid_instance_id", path .. ".instanceId", "후보 카드 인스턴스 ID가 올바르지 않습니다.")
end
if not isAsciiId(candidate.cardId) then
addError(errors, "invalid_card_id", path .. ".cardId", "후보 카드 ID가 올바르지 않습니다.")
end
if not isAsciiId(candidate.role) then
addError(errors, "invalid_role", path .. ".role", "후보 역할이 올바르지 않습니다.")
end
if not isInteger(candidate.handPosition, 1) then
addError(errors, "invalid_hand_position", path .. ".handPosition", "후보 손패 위치는 1 이상의 정수여야 합니다.")
end
if not isSafeInteger(candidate.score) then
addError(errors, "invalid_selection_score", path .. ".score", "후보 점수는 안전한 정수여야 합니다.")
end
if not isFinite(candidate.projectedPlayerStealth) then
addError(errors, "invalid_projected_stealth", path .. ".projectedPlayerStealth", "예상 플레이어 은폐가 유한한 숫자가 아닙니다.")
end
if candidate.lethal ~= true and candidate.lethal ~= false then
addError(errors, "invalid_lethal_flag", path .. ".lethal", "lethal은 불리언이어야 합니다.")
elseif isFinite(candidate.projectedPlayerStealth)
and candidate.lethal ~= (candidate.projectedPlayerStealth <= 0) then
addError(errors, "lethal_projection_mismatch", path .. ".lethal", "lethal 값이 예상 플레이어 은폐와 다릅니다.")
end
if not isSafeInteger(candidate.weight, 0) then
addError(errors, "invalid_selection_weight", path .. ".weight", "후보 가중치는 0 이상의 안전한 정수여야 합니다.")
end
if not isSafeInteger(candidate.moodScore) then
addError(errors, "invalid_mood_score", path .. ".moodScore", "후보 무드 점수는 안전한 정수여야 합니다.")
end
validateCandidateTotals(candidate.totals, path .. ".totals", errors)
if not isSafeInteger(candidate.planChargesEvaluated, 0) then
addError(
errors,
"invalid_plan_charge_count",
path .. ".planChargesEvaluated",
"평가한 계획 충전 수는 0 이상의 안전한 정수여야 합니다."
)
end
end
local function validateSelectionDraw(draw, path, errors)
if type(draw) ~= "table" then
addError(errors, "invalid_selection_draw", path, "선택 추첨 영수증이 객체가 아닙니다.")
return
end
if draw.kind == "pass" then
checkAllowedKeys(draw, { kind = true }, path, errors)
elseif draw.kind == "single" then
checkAllowedKeys(draw, { kind = true, totalWeight = true }, path, errors)
if not isSafeInteger(draw.totalWeight, 1) then
addError(errors, "invalid_draw_weight", path .. ".totalWeight", "단독 후보 가중치는 1 이상의 안전한 정수여야 합니다.")
end
elseif draw.kind == "weighted" then
checkAllowedKeys(draw, {
kind = true,
totalWeight = true,
roll = true,
}, path, errors)
if not isSafeInteger(draw.totalWeight, 1) then
addError(errors, "invalid_draw_weight", path .. ".totalWeight", "가중 추첨의 총 가중치는 1 이상의 안전한 정수여야 합니다.")
end
if not isSafeInteger(draw.roll, 1) then
addError(errors, "invalid_draw_roll", path .. ".roll", "가중 추첨 roll은 1 이상의 안전한 정수여야 합니다.")
elseif isSafeInteger(draw.totalWeight, 1) and draw.roll > draw.totalWeight then
addError(errors, "invalid_draw_roll", path .. ".roll", "가중 추첨 roll이 총 가중치를 초과합니다.")
end
else
checkAllowedKeys(draw, {
kind = true,
totalWeight = true,
roll = true,
}, path, errors)
addError(errors, "invalid_selection_draw", path .. ".kind", "알 수 없는 선택 추첨 방식입니다.")
end
end
local function findStateInstance(state, instanceId)
if type(state) ~= "table" or type(state.cardInstances) ~= "table" then
return nil
end
for _, instance in ipairs(state.cardInstances) do
if type(instance) == "table" and instance.instanceId == instanceId then
return instance
end
end
return nil
end
local function countStateZone(state, owner, zone)
local count = 0
for _, instance in ipairs(type(state) == "table" and type(state.cardInstances) == "table" and state.cardInstances or {}) do
if type(instance) == "table" and instance.owner == owner and instance.zone == zone then
count = count + 1
end
end
return count
end
local function buildExpectedSelectionContext(state, events, staticData)
local revealEventIds = {}
for _, event in ipairs(type(events) == "table" and events or {}) do
if type(event) == "table" and event.type == "role_revealed" and isRuntimeId(event.eventId) then
revealEventIds[event.eventId] = true
end
end
local playerStealth = type(state.player) == "table" and state.player.stealth or nil
local playerHandCount = countStateZone(state, "player", "hand")
local characterResistance = type(state.character) == "table" and state.character.resistance or nil
local characterMood = type(state.character) == "table" and state.character.mood or nil
local characterMoodTokens = {}
local stateMoodTokens = type(state.character) == "table" and type(state.character.moodTokens) == "table"
and state.character.moodTokens
or {}
local registryMoods = type(staticData) == "table"
and type(staticData.registry) == "table"
and type(staticData.registry.moods) == "table"
and staticData.registry.moods
or {}
for moodId in pairs(registryMoods) do
characterMoodTokens[moodId] = stateMoodTokens[moodId] or 0
end
local postSelectionCharacterDraws = {}
if type(events) == "table" then
for index = #events, 1, -1 do
local event = events[index]
local causedByReveal = type(event) == "table"
and event.type == "effect_applied"
and type(event.cause) == "table"
and revealEventIds[event.cause.eventId] == true
local payload = causedByReveal and event.payload or nil
if type(payload) == "table" then
if (payload.op == "lose_stealth" or payload.op == "recover_stealth")
and payload.target == "player"
and isFinite(payload.before) then
playerStealth = payload.before
elseif (payload.op == "recover_resistance" or payload.op == "damage_resistance")
and payload.target == "character"
and isFinite(payload.before) then
characterResistance = payload.before
elseif payload.op == "draw_cards" and payload.target == "player" then
local drawnCount = type(payload.drawnInstanceIds) == "table" and #payload.drawnInstanceIds or 0
playerHandCount = playerHandCount - drawnCount
elseif payload.op == "draw_cards" and payload.target == "character" then
for _, instanceId in ipairs(type(payload.drawnInstanceIds) == "table" and payload.drawnInstanceIds or {}) do
postSelectionCharacterDraws[instanceId] = true
end
elseif (payload.op == "add_mood_token" or payload.op == "remove_mood_token")
and payload.target == "character"
and isAsciiId(payload.mood)
and isSafeInteger(payload.before, 0) then
characterMoodTokens[payload.mood] = payload.before
end
end
end
end
local hand = {}
for sourceIndex, instance in ipairs(type(state.cardInstances) == "table" and state.cardInstances or {}) do
if type(instance) == "table"
and instance.owner == "character"
and instance.zone == "hand"
and not postSelectionCharacterDraws[instance.instanceId] then
hand[#hand + 1] = {
instance = instance,
sourceIndex = sourceIndex,
}
end
end
table.sort(hand, function(left, right)
if left.instance.position ~= right.instance.position then
return left.instance.position < right.instance.position
end
if left.instance.instanceId ~= right.instance.instanceId then
return left.instance.instanceId < right.instance.instanceId
end
return left.sourceIndex < right.sourceIndex
end)
local characterHand = {}
for index, entry in ipairs(hand) do
local instance = entry.instance
local card = type(staticData) == "table" and type(staticData.cards) == "table" and staticData.cards[instance.cardId] or nil
characterHand[index] = {
instanceId = instance.instanceId,
cardId = instance.cardId,
role = type(card) == "table" and card.roles[1] or nil,
handPosition = instance.position,
}
end
return {
turnNumber = state.turnNumber,
player = {
stealth = playerStealth,
handCount = playerHandCount,
},
character = {
resistance = characterResistance,
mood = characterMood,
moodTokens = characterMoodTokens,
},
characterHand = characterHand,
history = projectHistoryContext(state.history),
}
end
local function historyContextEqual(left, right, seen)
if type(left) ~= type(right) then return false end
if type(left) ~= "table" then return left == right end
seen = seen or {}
if seen[left] ~= nil then return seen[left] == right end
seen[left] = right
for key, value in pairs(left) do
if not historyContextEqual(value, right[key], seen) then return false end
end
for key in pairs(right) do
if left[key] == nil then return false end
end
return true
end
local function selectionContextEqual(left, right)
if type(left) ~= "table" or type(right) ~= "table"
or left.turnNumber ~= right.turnNumber
or type(left.player) ~= "table" or type(right.player) ~= "table"
or left.player.stealth ~= right.player.stealth
or left.player.handCount ~= right.player.handCount
or type(left.character) ~= "table" or type(right.character) ~= "table"
or left.character.resistance ~= right.character.resistance
or left.character.mood ~= right.character.mood
or not historyContextEqual(left.character.moodTokens, right.character.moodTokens)
or not historyContextEqual(left.history, right.history)
or type(left.characterHand) ~= "table" or type(right.characterHand) ~= "table"
or #left.characterHand ~= #right.characterHand then
return false
end
for index, expected in ipairs(right.characterHand) do
local actual = left.characterHand[index]
if type(actual) ~= "table"
or actual.instanceId ~= expected.instanceId
or actual.cardId ~= expected.cardId
or actual.role ~= expected.role
or actual.handPosition ~= expected.handPosition then
return false
end
end
return true
end
local function callReceiptValidator(moduleName, moduleAction, ...)
if type(runScript) ~= "function" then
return nil, "스크립트 실행기를 찾을 수 없습니다."
end
local ok, report = pcall(runScript, triggerId, moduleName, moduleAction, ...)
if not ok then
return nil, tostring(report)
end
if type(report) ~= "table" then
return nil, "하위 검증기가 테이블 결과를 반환하지 않았습니다."
end
return report, nil
end
local function validateCharacterSelection(selection, state, staticData, referencesValidated, events, path, errors)
if type(selection) ~= "table" then
addError(errors, "invalid_character_selection_receipt", path, "캐릭터 선택 영수증이 객체가 아닙니다.")
return
end
checkAllowedKeys(selection, {
schemaVersion = true,
kind = true,
battleId = true,
turnNumber = true,
characterId = true,
selectionContext = true,
candidates = true,
weightedPoolInstanceIds = true,
lethalPriorityApplied = true,
weightOffset = true,
rngBefore = true,
rngAfter = true,
draw = true,
selectedInstanceId = true,
selectedCardId = true,
publicRole = true,
}, path, errors)
if selection.schemaVersion ~= SCHEMA_VERSION then
addError(errors, "invalid_selection_schema", path .. ".schemaVersion", "지원하지 않는 캐릭터 선택 영수증 스키마입니다.")
end
if selection.kind ~= "characterIntentSelection" then
addError(errors, "invalid_selection_kind", path .. ".kind", "kind는 characterIntentSelection이어야 합니다.")
end
if not isRuntimeId(selection.battleId) then
addError(errors, "invalid_battle_id", path .. ".battleId", "선택 영수증 battleId가 올바르지 않습니다.")
elseif selection.battleId ~= state.battleId then
addError(errors, "selection_battle_mismatch", path .. ".battleId", "선택 영수증 battleId가 현재 전투와 다릅니다.")
end
if not isInteger(selection.turnNumber, 1) then
addError(errors, "invalid_turn_number", path .. ".turnNumber", "선택 영수증 턴 번호는 1 이상의 정수여야 합니다.")
elseif selection.turnNumber ~= state.turnNumber then
addError(errors, "selection_turn_mismatch", path .. ".turnNumber", "선택 영수증 턴 번호가 현재 전투와 다릅니다.")
end
if not isAsciiId(selection.characterId) then
addError(errors, "invalid_character_id", path .. ".characterId", "선택 영수증 캐릭터 ID가 올바르지 않습니다.")
elseif type(state.character) == "table" and selection.characterId ~= state.character.characterId then
addError(errors, "selection_character_mismatch", path .. ".characterId", "선택 영수증 캐릭터 ID가 현재 전투와 다릅니다.")
end
local contextPath = path .. ".selectionContext"
local contextHandCount = validateSelectionContext(selection.selectionContext, contextPath, errors, staticData)
if type(selection.selectionContext) == "table" then
if selection.selectionContext.turnNumber ~= selection.turnNumber then
addError(errors, "selection_context_turn_mismatch", contextPath .. ".turnNumber", "선택 시점 컨텍스트의 턴 번호가 선택 영수증과 다릅니다.")
end
local contextMood = type(selection.selectionContext.character) == "table"
and selection.selectionContext.character.mood
or nil
if referencesValidated and isAsciiId(contextMood) then
local moods = type(staticData.registry) == "table" and staticData.registry.moods or nil
if type(moods) ~= "table" or moods[contextMood] == nil then
addError(errors, "unknown_mood", contextPath .. ".character.mood", "선택 시점 무드가 정적 레지스트리에 없습니다.")
end
end
local expectedContext = referencesValidated and buildExpectedSelectionContext(state, events, staticData) or nil
if expectedContext ~= nil and not selectionContextEqual(selection.selectionContext, expectedContext) then
addError(
errors,
"selection_context_state_mismatch",
contextPath,
"선택 시점 컨텍스트가 현재 상태와 역할 공개 이후 효과를 역산한 결과와 다릅니다."
)
end
end
local candidateCount = getArrayLength(selection.candidates, path .. ".candidates", errors)
local candidateById = {}
local positionSeen = {}
if candidateCount ~= nil then
for index = 1, candidateCount do
local candidate = selection.candidates[index]
local candidatePath = path .. ".candidates[" .. index .. "]"
validateSelectionCandidate(candidate, candidatePath, errors)
if type(candidate) == "table" then
if isRuntimeId(candidate.instanceId) then
if candidateById[candidate.instanceId] then
addError(errors, "duplicate_selection_candidate", candidatePath .. ".instanceId", "후보 인스턴스 ID가 중복되었습니다.")
else
candidateById[candidate.instanceId] = candidate
end
end
if isInteger(candidate.handPosition, 1) then
if positionSeen[candidate.handPosition] then
addError(errors, "duplicate_hand_position", candidatePath .. ".handPosition", "후보 손패 위치가 중복되었습니다.")
end
positionSeen[candidate.handPosition] = true
end
end
end
end
if candidateCount ~= nil and contextHandCount ~= nil then
if candidateCount ~= contextHandCount then
addError(errors, "selection_candidate_count_mismatch", path .. ".candidates", "후보 목록이 선택 시점 캐릭터 손패 전체와 장수가 다릅니다.")
end
local comparisonCount = math.min(candidateCount, contextHandCount)
for index = 1, comparisonCount do
local candidate = selection.candidates[index]
local handEntry = selection.selectionContext.characterHand[index]
local candidatePath = path .. ".candidates[" .. index .. "]"
if type(candidate) == "table" and type(handEntry) == "table" then
if candidate.instanceId ~= handEntry.instanceId
or candidate.cardId ~= handEntry.cardId
or candidate.role ~= handEntry.role
or candidate.handPosition ~= handEntry.handPosition then
addError(errors, "selection_candidate_hand_mismatch", candidatePath, "후보의 순서·인스턴스·카드·역할·위치가 선택 시점 손패와 다릅니다.")
end
local totals = candidate.totals
if type(totals) == "table"
and isFinite(totals.recoverResistance)
and isFinite(totals.loseStealth)
and isFinite(totals.damageResistance)
and isFinite(totals.recoverStealth)
and isSafeInteger(candidate.moodScore) then
local directScore = totals.recoverResistance
+ totals.loseStealth
- totals.damageResistance
- totals.recoverStealth
local expectedScore = directScore * CHARACTER_SCORE_SCALE + candidate.moodScore
if candidate.score ~= expectedScore then
addError(errors, "selection_score_mismatch", candidatePath .. ".score", "후보 점수가 직접 효과와 무드 평가 합계와 다릅니다.")
end
local contextStealth = type(selection.selectionContext.player) == "table"
and selection.selectionContext.player.stealth
or nil
if isFinite(contextStealth) then
local expectedStealth = contextStealth
- totals.loseStealth
+ totals.recoverStealth
if candidate.projectedPlayerStealth ~= expectedStealth then
addError(errors, "selection_projection_mismatch", candidatePath .. ".projectedPlayerStealth", "예상 플레이어 은폐가 선택 시점 효과 합계와 다릅니다.")
end
end
end
if referencesValidated and isAsciiId(candidate.cardId) then
local card = staticData.cards[candidate.cardId]
local expectedCharges = 0
if type(card) == "table" and card.cardType == "plan" then
local plan = type(card.mechanismData) == "table" and card.mechanismData.plan or nil
expectedCharges = type(plan) == "table" and plan.charges or nil
end
if candidate.planChargesEvaluated ~= expectedCharges then
addError(errors, "selection_plan_charges_mismatch", candidatePath .. ".planChargesEvaluated", "평가한 계획 충전 수가 정적 카드 정의와 다릅니다.")
end
end
end
end
end
if referencesValidated and type(selection.selectionContext) == "table" then
local replayReport, replayCallError = callReceiptValidator(
"characterSelector",
"validateReceipt",
staticData,
selection
)
if replayCallError ~= nil then
addError(errors, "selection_replay_unavailable", path, "캐릭터 선택 효과 재생 검증을 실행할 수 없습니다: " .. replayCallError)
elseif replayReport.ok ~= true then
local nestedErrors = type(replayReport.errors) == "table" and replayReport.errors or {}
if #nestedErrors == 0 then
addError(errors, "selection_replay_failed", path, "캐릭터 선택 효과 재생 검증이 실패했습니다.")
else
for _, nested in ipairs(nestedErrors) do
local nestedPath = type(nested) == "table" and nested.path or "$"
if type(nestedPath) == "string" and string.sub(nestedPath, 1, 9) == "$.receipt" then
nestedPath = path .. string.sub(nestedPath, 10)
else
nestedPath = path
end
addError(
errors,
type(nested) == "table" and tostring(nested.code) or "selection_replay_failed",
nestedPath,
type(nested) == "table" and tostring(nested.message) or "캐릭터 선택 효과 재생 검증이 실패했습니다."
)
end
end
elseif replayReport.valid ~= true then
addError(errors, "selection_replay_failed", path, "캐릭터 선택 효과 재생 검증기가 valid 결과를 반환하지 않았습니다.")
end
end
validateIdArray(selection.weightedPoolInstanceIds, path .. ".weightedPoolInstanceIds", errors, isRuntimeId)
local poolCount = type(selection.weightedPoolInstanceIds) == "table" and #selection.weightedPoolInstanceIds or 0
local poolIdSet = {}
for index, instanceId in ipairs(type(selection.weightedPoolInstanceIds) == "table" and selection.weightedPoolInstanceIds or {}) do
if candidateById[instanceId] == nil then
addError(errors, "unknown_selection_pool_candidate", path .. ".weightedPoolInstanceIds[" .. index .. "]", "가중 추첨 후보가 candidates에 없습니다.")
elseif poolIdSet[instanceId] then
addError(errors, "duplicate_selection_pool_candidate", path .. ".weightedPoolInstanceIds[" .. index .. "]", "가중 추첨 후보가 중복되었습니다.")
else
poolIdSet[instanceId] = true
end
end
if selection.lethalPriorityApplied ~= true and selection.lethalPriorityApplied ~= false then
addError(errors, "invalid_lethal_priority", path .. ".lethalPriorityApplied", "lethalPriorityApplied는 불리언이어야 합니다.")
end
local beforeValid = validateReceiptRng(selection.rngBefore, path .. ".rngBefore", errors)
local afterValid = validateReceiptRng(selection.rngAfter, path .. ".rngAfter", errors)
if beforeValid and afterValid then
if selection.rngAfter.seed ~= selection.rngBefore.seed then
addError(errors, "receipt_rng_seed_changed", path .. ".rngAfter.seed", "캐릭터 선택 중 RNG seed가 바뀔 수 없습니다.")
end
if selection.rngAfter.cursor < selection.rngBefore.cursor then
addError(errors, "receipt_rng_reversed", path .. ".rngAfter.cursor", "캐릭터 선택 중 RNG cursor가 이전으로 돌아갈 수 없습니다.")
end
end
validateSelectionDraw(selection.draw, path .. ".draw", errors)
local intent = type(state) == "table" and state.characterIntent or nil
local intentIds = type(intent) == "table" and intent.cardInstanceIds or nil
local selected = selection.selectedInstanceId ~= nil
if not selected then
if selection.selectedCardId ~= nil
or selection.publicRole ~= nil
or selection.weightOffset ~= nil then
addError(errors, "selection_pass_field_conflict", path, "패스 영수증에는 선택 카드 필드를 둘 수 없습니다.")
end
if type(selection.draw) ~= "table" or selection.draw.kind ~= "pass" then
addError(errors, "selection_pass_draw_mismatch", path .. ".draw", "패스 영수증의 draw.kind는 pass여야 합니다.")
end
if candidateCount ~= nil and candidateCount ~= 0 then
addError(errors, "selection_pass_has_candidates", path .. ".candidates", "패스 영수증에는 후보가 없어야 합니다.")
end
if poolCount ~= 0 then
addError(errors, "selection_pass_has_pool", path .. ".weightedPoolInstanceIds", "패스 영수증에는 가중 추첨 후보가 없어야 합니다.")
end
if selection.lethalPriorityApplied ~= false then
addError(errors, "selection_pass_lethal_priority", path .. ".lethalPriorityApplied", "패스에는 치명 우선순위를 적용할 수 없습니다.")
end
if beforeValid and afterValid and not rngEqual(selection.rngBefore, selection.rngAfter) then
addError(errors, "selection_pass_consumed_rng", path .. ".rngAfter", "패스는 RNG를 소비할 수 없습니다.")
end
if type(intentIds) ~= "table" or #intentIds ~= 0 or (type(intent) == "table" and intent.publicRole ~= nil) then
addError(errors, "character_selection_intent_mismatch", "$.characterIntent", "패스 영수증과 현재 characterIntent가 다릅니다.")
end
return
end
if not isRuntimeId(selection.selectedInstanceId) then
addError(errors, "invalid_instance_id", path .. ".selectedInstanceId", "선택한 카드 인스턴스 ID가 올바르지 않습니다.")
end
if not isAsciiId(selection.selectedCardId) then
addError(errors, "invalid_card_id", path .. ".selectedCardId", "선택한 카드 ID가 올바르지 않습니다.")
end
if not isAsciiId(selection.publicRole) then
addError(errors, "invalid_role", path .. ".publicRole", "선택한 공개 역할이 올바르지 않습니다.")
end
if not isSafeInteger(selection.weightOffset, 0) then
addError(errors, "invalid_selection_weight_offset", path .. ".weightOffset", "점수 가중치 평행이동 값은 0 이상의 안전한 정수여야 합니다.")
end
if type(selection.draw) ~= "table"
or (selection.draw.kind ~= "single" and selection.draw.kind ~= "weighted") then
addError(errors, "selection_selected_draw_mismatch", path .. ".draw", "선택 영수증은 single 또는 weighted 추첨을 사용해야 합니다.")
end
if candidateCount == 0 then
addError(errors, "selection_missing_candidates", path .. ".candidates", "선택 영수증에는 후보가 필요합니다.")
end
if poolCount == 0 then
addError(errors, "selection_missing_weighted_pool", path .. ".weightedPoolInstanceIds", "선택 영수증에는 가중 추첨 후보가 필요합니다.")
end
local selectedCandidate = candidateById[selection.selectedInstanceId]
if selectedCandidate == nil then
addError(errors, "selected_candidate_missing", path .. ".selectedInstanceId", "선택한 인스턴스가 candidates에 없습니다.")
else
if selectedCandidate.cardId ~= selection.selectedCardId then
addError(errors, "selected_card_mismatch", path .. ".selectedCardId", "선택 카드 ID가 후보와 다릅니다.")
end
if selectedCandidate.role ~= selection.publicRole then
addError(errors, "selected_role_mismatch", path .. ".publicRole", "선택 역할이 후보와 다릅니다.")
end
end
local anyLethal = false
if type(selection.candidates) == "table" then
for _, candidate in ipairs(selection.candidates) do
if type(candidate) == "table" and candidate.lethal == true then
anyLethal = true
break
end
end
end
if (selection.lethalPriorityApplied == true) ~= anyLethal then
addError(errors, "lethal_priority_mismatch", path .. ".lethalPriorityApplied", "치명 우선순위가 후보의 lethal 값과 다릅니다.")
end
local expectedPool = {}
local expectedPoolSet = {}
local minimumPoolScore = nil
local anyPositivePoolScore = false
if type(selection.candidates) == "table" then
for _, candidate in ipairs(selection.candidates) do
if type(candidate) == "table"
and isRuntimeId(candidate.instanceId)
and isSafeInteger(candidate.score)
and (not anyLethal or candidate.lethal == true) then
expectedPool[#expectedPool + 1] = candidate.instanceId
expectedPoolSet[candidate.instanceId] = true
if minimumPoolScore == nil or candidate.score < minimumPoolScore then
minimumPoolScore = candidate.score
end
if candidate.score > 0 then
anyPositivePoolScore = true
end
end
end
end
local poolMatches = #expectedPool == poolCount
if poolMatches then
for index = 1, poolCount do
if expectedPool[index] ~= selection.weightedPoolInstanceIds[index] then
poolMatches = false
break
end
end
end
if not poolMatches then
addError(errors, "selection_weighted_pool_mismatch", path .. ".weightedPoolInstanceIds", "가중 추첨 후보 배열이 치명 우선순위 결과와 다릅니다.")
end
local expectedOffset = nil
if minimumPoolScore ~= nil then
expectedOffset = minimumPoolScore <= 0 and (1 - minimumPoolScore) or 0
if not isSafeInteger(expectedOffset, 0) then
addError(errors, "invalid_selection_weight_offset", path .. ".weightOffset", "후보 점수에서 안전한 평행이동 값을 계산할 수 없습니다.")
elseif selection.weightOffset ~= expectedOffset then
addError(errors, "selection_weight_offset_mismatch", path .. ".weightOffset", "점수 가중치 평행이동 값이 후보 점수와 다릅니다.")
end
end
local totalWeight = 0
if type(selection.candidates) == "table" then
for index, candidate in ipairs(selection.candidates) do
if type(candidate) == "table" and isSafeInteger(candidate.score) then
local expectedWeight = 0
if expectedPoolSet[candidate.instanceId] and isSafeInteger(expectedOffset, 0) then
local adjusted = candidate.score + expectedOffset
if isSafeInteger(adjusted) and adjusted > 0 then
expectedWeight = adjusted
end
end
if candidate.weight ~= expectedWeight then
addError(errors, "selection_weight_mismatch", path .. ".candidates[" .. index .. "].weight", "후보 가중치가 총효과 점수 정책과 다릅니다.")
end
if expectedPoolSet[candidate.instanceId] and isSafeInteger(expectedWeight, 0) then
if isSafeInteger(totalWeight + expectedWeight, 0) then
totalWeight = totalWeight + expectedWeight
else
addError(errors, "selection_total_weight_overflow", path .. ".candidates", "가중 추첨 총합이 안전한 정수 범위를 벗어났습니다.")
end
end
end
end
end
if totalWeight <= 0 and poolCount > 0 then
addError(errors, "selection_zero_total_weight", path .. ".candidates", "가중 추첨 후보의 총 가중치는 1 이상이어야 합니다.")
end
if selectedCandidate ~= nil and not expectedPoolSet[selection.selectedInstanceId] then
addError(errors, "selected_candidate_outside_pool", path .. ".selectedInstanceId", "선택된 카드는 가중 추첨 후보에 포함되어야 합니다.")
end
local draw = selection.draw
if type(draw) == "table" and draw.kind == "single" then
if poolCount ~= 1 or selection.weightedPoolInstanceIds[1] ~= selection.selectedInstanceId then
addError(errors, "single_draw_mismatch", path .. ".draw", "single 추첨은 유일한 가중 추첨 후보를 선택해야 합니다.")
elseif draw.totalWeight ~= totalWeight then
addError(errors, "draw_weight_mismatch", path .. ".draw.totalWeight", "single 총 가중치가 후보 가중치와 다릅니다.")
end
if beforeValid and afterValid and not rngEqual(selection.rngBefore, selection.rngAfter) then
addError(errors, "single_selection_consumed_rng", path .. ".rngAfter", "single 선택은 RNG를 소비할 수 없습니다.")
end
elseif type(draw) == "table" and draw.kind == "weighted" then
if poolCount < 2 then
addError(errors, "weighted_draw_missing_candidates", path .. ".weightedPoolInstanceIds", "weighted 추첨에는 후보가 둘 이상 필요합니다.")
end
if draw.totalWeight ~= totalWeight then
addError(errors, "draw_weight_mismatch", path .. ".draw.totalWeight", "weighted 총 가중치가 추첨 후보 가중치 합과 다릅니다.")
end
if isSafeInteger(draw.roll, 1) then
local cumulative = 0
local rolledInstanceId = nil
for _, instanceId in ipairs(type(selection.weightedPoolInstanceIds) == "table" and selection.weightedPoolInstanceIds or {}) do
local candidate = candidateById[instanceId]
if candidate and isSafeInteger(candidate.weight, 0) then
cumulative = cumulative + candidate.weight
if candidate.weight > 0 and draw.roll <= cumulative then
rolledInstanceId = instanceId
break
end
end
end
if rolledInstanceId ~= selection.selectedInstanceId then
addError(errors, "weighted_result_mismatch", path .. ".selectedInstanceId", "weighted roll 결과와 선택 인스턴스가 다릅니다.")
end
end
if beforeValid and isSafeInteger(totalWeight, 1) then
local rngReplay, rngReplayCallError = callReceiptValidator(
"deterministicRng",
"nextInteger",
selection.rngBefore,
1,
totalWeight
)
if rngReplayCallError ~= nil then
addError(errors, "selection_rng_replay_unavailable", path .. ".draw", "선택 RNG를 재생할 수 없습니다: " .. rngReplayCallError)
elseif rngReplay.ok ~= true then
addError(errors, "selection_rng_replay_failed", path .. ".draw", "결정적 선택 RNG 재생이 실패했습니다.")
else
if rngReplay.value ~= draw.roll then
addError(errors, "selection_roll_replay_mismatch", path .. ".draw.roll", "기록된 roll이 rngBefore와 총 가중치의 결정적 재생 결과와 다릅니다.")
end
if afterValid and not rngEqual(rngReplay.rng, selection.rngAfter) then
addError(errors, "selection_rng_after_replay_mismatch", path .. ".rngAfter", "기록된 rngAfter가 결정적 가중 추첨 재생 결과와 다릅니다.")
end
end
end
if beforeValid and afterValid and selection.rngAfter.cursor <= selection.rngBefore.cursor then
addError(errors, "weighted_selection_rng_missing", path .. ".rngAfter.cursor", "weighted 선택은 RNG cursor를 전진시켜야 합니다.")
end
end
if type(intentIds) ~= "table"
or #intentIds ~= 1
or intentIds[1] ~= selection.selectedInstanceId
or type(intent) ~= "table"
or intent.publicRole ~= selection.publicRole then
addError(errors, "character_selection_intent_mismatch", "$.characterIntent", "선택 영수증과 현재 characterIntent가 다릅니다.")
end
local instance = findStateInstance(state, selection.selectedInstanceId)
if instance == nil
or instance.owner ~= "character"
or instance.zone ~= "hand"
or instance.cardId ~= selection.selectedCardId then
addError(errors, "selected_instance_mismatch", path .. ".selectedInstanceId", "선택 인스턴스가 현재 캐릭터 손패·카드와 일치하지 않습니다.")
end
if referencesValidated and isAsciiId(selection.selectedCardId) then
local card = staticData.cards[selection.selectedCardId]
if type(card) ~= "table"
or card.owner ~= "character"
or card.roles[1] ~= selection.publicRole then
addError(errors, "selected_static_card_mismatch", path .. ".selectedCardId", "선택 카드와 정적 카드 역할이 일치하지 않습니다.")
end
end
end
local function validateTurnStartReceipt(receipt, state, staticData, referencesValidated, errors)
local path = "$.turnStartReceipt"
if type(receipt) ~= "table" then
addError(errors, "invalid_turn_start_receipt", path, "turnStartReceipt가 객체가 아닙니다.")
return
end
checkAllowedKeys(receipt, {
schemaVersion = true,
kind = true,
turnId = true,
turnNumber = true,
authorityFingerprint = true,
draws = true,
characterSelection = true,
baseline = true,
transient = true,
events = true,
}, path, errors)
if receipt.schemaVersion ~= SCHEMA_VERSION then
addError(errors, "invalid_receipt_schema", path .. ".schemaVersion", "지원하지 않는 turnStartReceipt 스키마입니다.")
end
if receipt.kind ~= "turnStartReceipt" then
addError(errors, "invalid_receipt_kind", path .. ".kind", "kind는 turnStartReceipt여야 합니다.")
end
if not isRuntimeId(receipt.turnId) then
addError(errors, "invalid_turn_id", path .. ".turnId", "turnStartReceipt turnId가 올바르지 않습니다.")
elseif state.lastCommittedTurnId == receipt.turnId then
addError(errors, "turn_already_committed", path .. ".turnId", "이미 확정한 turnId를 현재 턴 초기화에 사용할 수 없습니다.")
end
if not isInteger(receipt.turnNumber, 1) then
addError(errors, "invalid_turn_number", path .. ".turnNumber", "turnStartReceipt 턴 번호는 1 이상의 정수여야 합니다.")
elseif receipt.turnNumber ~= state.turnNumber then
addError(errors, "receipt_turn_mismatch", path .. ".turnNumber", "turnStartReceipt 턴 번호가 현재 전투 턴과 다릅니다.")
end
if state.status ~= "active" then
addError(errors, "receipt_requires_active", path, "turnStartReceipt는 active 전투 상태에만 존재할 수 있습니다.")
end
local fingerprintPath = path .. ".authorityFingerprint"
local fingerprintValid = validateAuthorityFingerprint(receipt.authorityFingerprint, fingerprintPath, errors)
if fingerprintValid then
local expectedFingerprint, fingerprintError = fingerprintAuthorityState(state)
if fingerprintError then
addError(errors, fingerprintError.code, fingerprintError.path, fingerprintError.message)
elseif receipt.authorityFingerprint.algorithm ~= expectedFingerprint.algorithm
or receipt.authorityFingerprint.length ~= expectedFingerprint.length
or receipt.authorityFingerprint.hashA ~= expectedFingerprint.hashA
or receipt.authorityFingerprint.hashB ~= expectedFingerprint.hashB then
addError(
errors,
"receipt_authority_mismatch",
fingerprintPath,
"turnStartReceipt가 현재 권위 battleState와 일치하지 않습니다."
)
end
end
validateDraws(receipt.draws, state, path .. ".draws", errors)
validateCharacterSelection(
receipt.characterSelection,
state,
staticData,
referencesValidated,
receipt.events,
path .. ".characterSelection",
errors
)
local characterDrawAfter = type(receipt.draws) == "table"
and type(receipt.draws.character) == "table"
and receipt.draws.character.rngAfter
or nil
local selectionRngBefore = type(receipt.characterSelection) == "table"
and receipt.characterSelection.rngBefore
or nil
if type(characterDrawAfter) == "table"
and type(selectionRngBefore) == "table"
and not rngEqual(characterDrawAfter, selectionRngBefore) then
addError(
errors,
"receipt_rng_discontinuity",
path .. ".characterSelection.rngBefore",
"캐릭터 선택 RNG 시작점이 캐릭터 드로우 종료점과 다릅니다."
)
end
local baseline = receipt.baseline
if type(baseline) ~= "table" then
addError(errors, "invalid_receipt_baseline", path .. ".baseline", "turnStartReceipt baseline이 객체가 아닙니다.")
else
checkAllowedKeys(baseline, {
stealth = true,
resistance = true,
mood = true,
moodTokens = true,
}, path .. ".baseline", errors)
if not isFinite(baseline.stealth) then
addError(errors, "invalid_receipt_baseline", path .. ".baseline.stealth", "baseline 은폐가 유한한 숫자가 아닙니다.")
end
if not isFinite(baseline.resistance) then
addError(errors, "invalid_receipt_baseline", path .. ".baseline.resistance", "baseline 저항이 유한한 숫자가 아닙니다.")
end
if not isAsciiId(baseline.mood) then
addError(errors, "invalid_receipt_baseline", path .. ".baseline.mood", "baseline 무드 ID가 올바르지 않습니다.")
elseif referencesValidated then
local moods = type(staticData.registry) == "table" and staticData.registry.moods or nil
if type(moods) ~= "table" or not moods[baseline.mood] then
addError(errors, "unknown_mood", path .. ".baseline.mood", "baseline 무드가 레지스트리에 없습니다.")
end
end
if type(baseline.moodTokens) ~= "table" then
addError(errors, "invalid_receipt_baseline", path .. ".baseline.moodTokens", "baseline 무드 토큰이 객체가 아닙니다.")
elseif referencesValidated then
local moods = staticData.registry.moods
for moodId in pairs(moods) do
if not isSafeInteger(baseline.moodTokens[moodId], 0) then
addError(errors, "invalid_mood_token_count", path .. ".baseline.moodTokens." .. moodId, "무드 토큰 수는 0 이상의 안전한 정수여야 합니다.")
end
end
for moodId in pairs(baseline.moodTokens) do
if not moods[moodId] then
addError(errors, "unknown_mood", path .. ".baseline.moodTokens." .. tostring(moodId), "등록되지 않은 무드의 토큰입니다.")
end
end
end
end
local transient = receipt.transient
if type(transient) ~= "table" then
addError(errors, "invalid_receipt_transient", path .. ".transient", "turnStartReceipt transient가 객체가 아닙니다.")
else
checkAllowedKeys(transient, {
skipRemaining = true,
forcedMoodRequests = true,
}, path .. ".transient", errors)
local skipRemaining = transient.skipRemaining
if type(skipRemaining) ~= "table" then
addError(errors, "invalid_skip_state", path .. ".transient.skipRemaining", "skipRemaining이 객체가 아닙니다.")
else
checkAllowedKeys(skipRemaining, {
player = true,
character = true,
}, path .. ".transient.skipRemaining", errors)
for _, side in ipairs({ "player", "character" }) do
if skipRemaining[side] ~= true and skipRemaining[side] ~= false then
addError(errors, "invalid_skip_state", path .. ".transient.skipRemaining." .. side, "skipRemaining 값은 불리언이어야 합니다.")
end
end
end
local forcedRequestCount = getArrayLength(
transient.forcedMoodRequests,
path .. ".transient.forcedMoodRequests",
errors
)
if forcedRequestCount then
for index = 1, forcedRequestCount do
local request = transient.forcedMoodRequests[index]
local requestPath = path .. ".transient.forcedMoodRequests[" .. index .. "]"
if type(request) ~= "table" then
addError(errors, "invalid_forced_mood_request", requestPath, "무드 강제 변경 요청이 객체가 아닙니다.")
else
checkAllowedKeys(request, {
mood = true,
cause = true,
}, requestPath, errors)
if not isAsciiId(request.mood) then
addError(errors, "invalid_forced_mood_request", requestPath .. ".mood", "강제 변경 무드 ID가 올바르지 않습니다.")
elseif referencesValidated and not staticData.registry.moods[request.mood] then
addError(errors, "unknown_mood", requestPath .. ".mood", "강제 변경 무드가 레지스트리에 없습니다.")
end
if not isAsciiId(request.cause) then
addError(errors, "invalid_forced_mood_cause", requestPath .. ".cause", "강제 변경 cause는 lower_snake_case여야 합니다.")
end
end
end
end
end
validateTurnStartEvents(receipt.events, receipt.turnId, path .. ".events", errors)
end
local function subwayStationsAdjacent(line, leftId, rightId)
for _, routePath in ipairs(type(line) == "table" and line.paths or {}) do
local stations = routePath.stations
for index = 1, #stations - 1 do
local left = stations[index].id
local right = stations[index + 1].id
if (left == leftId and right == rightId)
or (left == rightId and right == leftId) then
return true
end
end
if routePath.circular == true and #stations > 1 then
local first = stations[1].id
local last = stations[#stations].id
if (first == leftId and last == rightId)
or (first == rightId and last == leftId) then
return true
end
end
end
return false
end
local function subwayStationExists(line, stationId)
for _, routePath in ipairs(type(line) == "table" and line.paths or {}) do
for _, station in ipairs(routePath.stations or {}) do
if station.id == stationId then
return true
end
end
end
return false
end
local function transitEqual(left, right)
if type(left) ~= "table" or type(right) ~= "table"
or left.algorithm ~= right.algorithm
or left.lineId ~= right.lineId
or type(left.stationIds) ~= "table"
or type(right.stationIds) ~= "table"
or #left.stationIds ~= #right.stationIds then
return false
end
for index = 1, #left.stationIds do
if left.stationIds[index] ~= right.stationIds[index] then
return false
end
end
return true
end
local function sceneContextEqual(left, right)
return type(left) == "table"
and type(right) == "table"
and left.weekday == right.weekday
and left.time == right.time
and left.weather == right.weather
end
local function validateSceneContext(sceneContext, errors)
local path = "$.sceneContext"
if type(sceneContext) ~= "table" then
addError(errors, "invalid_scene_context", path, "전투 배경 정보가 테이블이 아닙니다.")
return
end
checkAllowedKeys(sceneContext, { weekday = true, time = true, weather = true }, path, errors)
if VALID_SCENE_WEEKDAYS[sceneContext.weekday] ~= true then
addError(errors, "invalid_scene_weekday", path .. ".weekday", "전투 배경 요일이 올바르지 않습니다.")
end
if VALID_SCENE_TIMES[sceneContext.time] ~= true then
addError(errors, "invalid_scene_time", path .. ".time", "전투 배경 시간대가 올바르지 않습니다.")
end
if VALID_SCENE_WEATHERS[sceneContext.weather] ~= true then
addError(errors, "invalid_scene_weather", path .. ".weather", "전투 배경 날씨가 올바르지 않습니다.")
end
local weekdayIsWeekend = sceneContext.weekday == "토요일" or sceneContext.weekday == "일요일"
local timeIsWeekend = sceneContext.time == "아침 시간" or sceneContext.time == "저녁 시간"
if weekdayIsWeekend ~= timeIsWeekend
and sceneContext.time ~= "한낮"
and sceneContext.time ~= "심야열차" then
addError(errors, "scene_schedule_mismatch", path .. ".time", "요일과 시간대 구분이 서로 맞지 않습니다.")
end
end
local function validateTransit(transit, turnLimit, staticData, errors)
local path = "$.transit"
if type(transit) ~= "table" then
addError(errors, "invalid_transit", path, "전투 이동 구간이 테이블이 아닙니다.")
return
end
checkAllowedKeys(transit, {
algorithm = true,
lineId = true,
stationIds = true,
}, path, errors)
if transit.algorithm ~= "tokyo_subway_segment_v1" then
addError(errors, "invalid_transit_algorithm", path .. ".algorithm", "지원하지 않는 지하철 여정 알고리즘입니다.")
end
if not isAsciiId(transit.lineId) then
addError(errors, "invalid_transit_line_id", path .. ".lineId", "지하철 노선 ID가 올바르지 않습니다.")
end
local stationCount = getArrayLength(transit.stationIds, path .. ".stationIds", errors)
if stationCount ~= nil then
if isInteger(turnLimit, 1) and stationCount ~= turnLimit + 1 then
addError(errors, "transit_length_mismatch", path .. ".stationIds", "이동 역 수는 제한 턴보다 하나 많아야 합니다.")
end
local seen = {}
for index = 1, stationCount do
local stationId = transit.stationIds[index]
if not isAsciiId(stationId) then
addError(errors, "invalid_transit_station_id", path .. ".stationIds[" .. index .. "]", "이동 역 ID가 올바르지 않습니다.")
elseif seen[stationId] then
addError(errors, "duplicate_transit_station", path .. ".stationIds[" .. index .. "]", "한 여정에서 같은 역을 두 번 지날 수 없습니다.")
else
seen[stationId] = true
end
end
end
if type(staticData) == "table" and type(staticData.subwayLines) == "table" then
local line = staticData.subwayLines[transit.lineId]
if type(line) ~= "table" then
addError(errors, "unknown_transit_line", path .. ".lineId", "정적 DB에서 지하철 노선을 찾을 수 없습니다.")
elseif stationCount ~= nil then
for index = 1, stationCount do
local stationId = transit.stationIds[index]
if isAsciiId(stationId) and not subwayStationExists(line, stationId) then
addError(errors, "unknown_transit_station", path .. ".stationIds[" .. index .. "]", "선택 노선에서 이동 역을 찾을 수 없습니다.")
end
if index > 1 then
local previousId = transit.stationIds[index - 1]
if isAsciiId(previousId)
and isAsciiId(stationId)
and not subwayStationsAdjacent(line, previousId, stationId) then
addError(errors, "non_adjacent_transit_station", path .. ".stationIds[" .. index .. "]", "이동 구간에 서로 인접하지 않은 역이 있습니다.")
end
end
end
end
end
end
local function validateBattleState(state, staticData)
local errors = {}
local staticDataProvided = staticData ~= nil
staticData = normalizeStaticData(staticData)
local referencesValidated = hasCompleteStaticData(staticData)
if staticDataProvided and not referencesValidated then
addError(errors, "invalid_static_data", "$", "전달한 정적 데이터가 전체 참조 검증에 필요한 컬렉션을 갖추지 못했습니다.")
end
if not referencesValidated then
staticData = nil
end
if type(state) ~= "table" then
addError(errors, "invalid_state", "$", "battleState가 테이블이 아닙니다.")
return result(errors)
end
validateJsonSafe(state, "$", errors)
if #errors > 0 then
local report = result(errors)
report.referencesValidated = referencesValidated
return report
end
checkAllowedKeys(state, {
schemaVersion = true,
kind = true,
battleId = true,
status = true,
turnNumber = true,
turnLimit = true,
transit = true,
sceneContext = true,
lastCommittedTurnId = true,
rng = true,
player = true,
character = true,
cardInstances = true,
selection = true,
characterIntent = true,
history = true,
turnStartReceipt = true,
}, "$", errors)
if state.schemaVersion ~= SCHEMA_VERSION then
addError(errors, "unsupported_schema", "$.schemaVersion", "지원하지 않는 battleState 스키마 버전입니다.")
end
if state.kind ~= "battleState" then
addError(errors, "invalid_kind", "$.kind", "kind는 battleState여야 합니다.")
end
if not isRuntimeId(state.battleId) then
addError(errors, "invalid_battle_id", "$.battleId", "battleId가 올바르지 않습니다.")
end
if not VALID_STATUS[state.status] then
addError(errors, "invalid_status", "$.status", "알 수 없는 전투 상태입니다.")
end
if not isInteger(state.turnNumber, 1) then
addError(errors, "invalid_turn_number", "$.turnNumber", "현재 턴은 1 이상의 정수여야 합니다.")
end
if not isInteger(state.turnLimit, 7) or state.turnLimit > 12 then
addError(errors, "invalid_turn_limit", "$.turnLimit", "제한 턴은 7 이상 12 이하의 정수여야 합니다.")
elseif isInteger(state.turnNumber, 1) and state.turnNumber > state.turnLimit then
addError(errors, "turn_over_limit", "$.turnNumber", "현재 턴이 제한 턴을 초과했습니다.")
end
validateTransit(state.transit, state.turnLimit, staticData, errors)
validateSceneContext(state.sceneContext, errors)
if state.lastCommittedTurnId ~= nil and not isRuntimeId(state.lastCommittedTurnId) then
addError(errors, "invalid_turn_id", "$.lastCommittedTurnId", "마지막 확정 턴 ID가 올바르지 않습니다.")
end
if type(state.rng) ~= "table" then
addError(errors, "invalid_rng", "$.rng", "rng가 테이블이 아닙니다.")
else
checkAllowedKeys(state.rng, { seed = true, cursor = true }, "$.rng", errors)
if not isSafeInteger(state.rng.seed, 0) then
addError(errors, "invalid_rng_seed", "$.rng.seed", "난수 시드는 0 이상이고 안전한 숫자 범위 안의 정수여야 합니다.")
end
if not isSafeInteger(state.rng.cursor, 0) then
addError(errors, "invalid_rng_cursor", "$.rng.cursor", "난수 커서는 0 이상이고 안전한 숫자 범위 안의 정수여야 합니다.")
end
end
if type(state.player) ~= "table" then
addError(errors, "invalid_player", "$.player", "player가 테이블이 아닙니다.")
else
checkAllowedKeys(state.player, {
stealth = true,
baseDrawCount = true,
maxHandSize = true,
perkIds = true,
planCapacity = true,
planSlots = true,
}, "$.player", errors)
if not isFinite(state.player.stealth) then
addError(errors, "invalid_stealth", "$.player.stealth", "은폐는 유한한 숫자여야 합니다.")
end
if not isInteger(state.player.baseDrawCount, 1) then
addError(errors, "invalid_base_draw_count", "$.player.baseDrawCount", "기본 드로우 수는 1 이상의 정수여야 합니다.")
end
if not isInteger(state.player.maxHandSize, 1) then
addError(errors, "invalid_hand_size", "$.player.maxHandSize", "최대 손패는 1 이상의 정수여야 합니다.")
end
if isInteger(state.player.baseDrawCount, 1)
and isInteger(state.player.maxHandSize, 1)
and state.player.baseDrawCount > state.player.maxHandSize then
addError(errors, "draw_exceeds_hand_limit", "$.player.baseDrawCount", "기본 드로우 수는 최대 손패보다 클 수 없습니다.")
end
validateIdArray(state.player.perkIds, "$.player.perkIds", errors, isAsciiId)
end
if type(state.character) ~= "table" then
addError(errors, "invalid_character", "$.character", "character가 테이블이 아닙니다.")
else
checkAllowedKeys(state.character, {
characterId = true,
resistance = true,
mood = true,
moodTokens = true,
traitIds = true,
baseDrawCount = true,
maxHandSize = true,
planCapacity = true,
planSlots = true,
}, "$.character", errors)
if not isAsciiId(state.character.characterId) then
addError(errors, "invalid_character_id", "$.character.characterId", "캐릭터 ID가 올바르지 않습니다.")
end
if not isFinite(state.character.resistance) then
addError(errors, "invalid_resistance", "$.character.resistance", "저항은 유한한 숫자여야 합니다.")
end
if not isAsciiId(state.character.mood) then
addError(errors, "invalid_mood", "$.character.mood", "무드 ID가 올바르지 않습니다.")
end
if state.character.moodTokens ~= nil then
if type(state.character.moodTokens) ~= "table" then
addError(errors, "invalid_mood_tokens", "$.character.moodTokens", "무드 토큰이 객체가 아닙니다.")
elseif referencesValidated then
local moods = staticData.registry.moods
for moodId in pairs(moods) do
if not isSafeInteger(state.character.moodTokens[moodId], 0) then
addError(errors, "invalid_mood_token_count", "$.character.moodTokens." .. moodId, "무드 토큰 수는 0 이상의 안전한 정수여야 합니다.")
end
end
for moodId in pairs(state.character.moodTokens) do
if not moods[moodId] then
addError(errors, "unknown_mood", "$.character.moodTokens." .. tostring(moodId), "등록되지 않은 무드의 토큰입니다.")
end
end
end
end
if not isInteger(state.character.baseDrawCount, 1) then
addError(errors, "invalid_base_draw_count", "$.character.baseDrawCount", "기본 드로우 수는 1 이상의 정수여야 합니다.")
end
if not isInteger(state.character.maxHandSize, 1) then
addError(errors, "invalid_hand_size", "$.character.maxHandSize", "최대 손패는 1 이상의 정수여야 합니다.")
end
if isInteger(state.character.baseDrawCount, 1)
and isInteger(state.character.maxHandSize, 1)
and state.character.baseDrawCount > state.character.maxHandSize then
addError(errors, "draw_exceeds_hand_limit", "$.character.baseDrawCount", "기본 드로우 수는 최대 손패보다 클 수 없습니다.")
end
validateIdArray(state.character.traitIds, "$.character.traitIds", errors, isAsciiId)
end
local instances = {}
local positions = {}
local instanceCount = getArrayLength(state.cardInstances, "$.cardInstances", errors)
if instanceCount then
for index = 1, instanceCount do
local item = state.cardInstances[index]
local path = "$.cardInstances[" .. index .. "]"
if type(item) ~= "table" then
addError(errors, "invalid_card_instance", path, "카드 인스턴스가 테이블이 아닙니다.")
else
checkAllowedKeys(item, {
instanceId = true,
cardId = true,
owner = true,
zone = true,
position = true,
temporaryModifiers = true,
}, path, errors)
if not isRuntimeId(item.instanceId) then
addError(errors, "invalid_instance_id", path .. ".instanceId", "카드 인스턴스 ID가 올바르지 않습니다.")
elseif instances[item.instanceId] then
addError(errors, "duplicate_instance_id", path .. ".instanceId", "카드 인스턴스 ID가 중복되었습니다.")
else
instances[item.instanceId] = item
end
if not isAsciiId(item.cardId) then
addError(errors, "invalid_card_id", path .. ".cardId", "카드 ID가 올바르지 않습니다.")
end
if not VALID_OWNER[item.owner] then
addError(errors, "invalid_owner", path .. ".owner", "카드 소유자가 올바르지 않습니다.")
end
if not VALID_ZONE[item.zone] then
addError(errors, "invalid_zone", path .. ".zone", "카드 영역이 올바르지 않습니다.")
end
if not isInteger(item.position, 1) then
addError(errors, "invalid_position", path .. ".position", "카드 위치는 1 이상의 정수여야 합니다.")
elseif VALID_OWNER[item.owner] and VALID_ZONE[item.zone] then
local group = item.owner .. ":" .. item.zone
positions[group] = positions[group] or {}
positions[group][item.position] = (positions[group][item.position] or 0) + 1
end
if item.temporaryModifiers ~= nil then
local modifierCount = getArrayLength(item.temporaryModifiers, path .. ".temporaryModifiers", errors)
if modifierCount and modifierCount > 0 then
addError(
errors,
"temporary_modifier_schema_pending",
path .. ".temporaryModifiers",
"임시 카드 보정 스키마가 확정되기 전에는 빈 배열만 허용합니다."
)
end
end
local cards = type(staticData) == "table" and staticData.cards or nil
local card = type(cards) == "table" and cards[item.cardId] or nil
if type(cards) == "table" and not card then
addError(errors, "unknown_card", path .. ".cardId", "정적 DB에서 카드를 찾을 수 없습니다.")
elseif card and card.owner ~= item.owner then
addError(errors, "card_owner_mismatch", path .. ".owner", "정적 카드와 인스턴스의 소유자가 다릅니다.")
end
end
end
end
for group, groupPositions in pairs(positions) do
local count = 0
local maximum = 0
for position, occurrence in pairs(groupPositions) do
count = count + occurrence
if position > maximum then
maximum = position
end
if occurrence > 1 then
addError(errors, "duplicate_position", "$.cardInstances", group .. " 영역의 position이 중복되었습니다: " .. position)
end
end
if count ~= maximum then
addError(errors, "non_contiguous_positions", "$.cardInstances", group .. " 영역의 position이 1부터 이어지지 않습니다.")
end
if group == "player:hand"
and type(state.player) == "table"
and isInteger(state.player.maxHandSize, 1)
and count > state.player.maxHandSize then
addError(errors, "hand_limit_exceeded", "$.cardInstances", "플레이어 손패가 최대 손패를 초과했습니다.")
elseif group == "character:hand"
and type(state.character) == "table"
and isInteger(state.character.maxHandSize, 1)
and count > state.character.maxHandSize then
addError(errors, "hand_limit_exceeded", "$.cardInstances", "캐릭터 손패가 최대 손패를 초과했습니다.")
end
end
if type(state.player) == "table" then
validatePlanSlots(
state.player.planSlots,
state.player.planCapacity,
"player",
"$.player.planSlots",
errors,
instances,
staticData,
state.turnNumber
)
end
if type(state.character) == "table" then
validatePlanSlots(
state.character.planSlots,
state.character.planCapacity,
"character",
"$.character.planSlots",
errors,
instances,
staticData,
state.turnNumber
)
end
for instanceId, instance in pairs(instances) do
if instance.zone == "plan" and VALID_OWNER[instance.owner] then
local ownerState = instance.owner == "player" and state.player or state.character
local slots = type(ownerState) == "table" and ownerState.planSlots or nil
local slot = type(slots) == "table" and slots[instance.position] or nil
if type(slot) ~= "table"
or slot.occupied ~= true
or slot.cardInstanceId ~= instanceId then
addError(
errors,
"orphan_plan_instance",
"$.cardInstances",
"plan 영역의 카드 인스턴스는 같은 진영의 점유 슬롯과 정확히 연결되어야 합니다: " .. instanceId
)
end
end
end
if type(state.selection) ~= "table" then
addError(errors, "invalid_selection", "$.selection", "selection이 테이블이 아닙니다.")
else
checkAllowedKeys(state.selection, { playerCardInstanceIds = true }, "$.selection", errors)
validateIdArray(state.selection.playerCardInstanceIds, "$.selection.playerCardInstanceIds", errors, isRuntimeId)
if type(state.selection.playerCardInstanceIds) == "table" then
local mainActionCount = 0
local mainActionIndex = nil
for index, instanceId in ipairs(state.selection.playerCardInstanceIds) do
local instance = instances[instanceId]
if not instance or instance.owner ~= "player" or instance.zone ~= "hand" then
addError(errors, "invalid_selected_card", "$.selection.playerCardInstanceIds[" .. index .. "]", "선택 카드는 플레이어 손패에 있어야 합니다.")
elseif referencesValidated then
local card = staticData.cards[instance.cardId]
if card and card.cardType ~= "chain" then
mainActionCount = mainActionCount + 1
mainActionIndex = index
end
end
end
if mainActionCount > 1 then
addError(errors, "multiple_player_main_actions", "$.selection.playerCardInstanceIds", "플레이어 선택에는 주 행동을 둘 이상 넣을 수 없습니다.")
elseif mainActionIndex and mainActionIndex ~= #state.selection.playerCardInstanceIds then
addError(errors, "player_main_action_not_last", "$.selection.playerCardInstanceIds", "연계 카드는 주 행동보다 앞에 있어야 합니다.")
end
end
end
if type(state.characterIntent) ~= "table" then
addError(errors, "invalid_character_intent", "$.characterIntent", "characterIntent가 테이블이 아닙니다.")
else
checkAllowedKeys(state.characterIntent, {
cardInstanceIds = true,
publicRole = true,
}, "$.characterIntent", errors)
validateIdArray(state.characterIntent.cardInstanceIds, "$.characterIntent.cardInstanceIds", errors, isRuntimeId)
local intentCount = type(state.characterIntent.cardInstanceIds) == "table"
and #state.characterIntent.cardInstanceIds
or 0
if intentCount == 0 then
if state.characterIntent.publicRole ~= nil then
addError(errors, "orphan_public_role", "$.characterIntent.publicRole", "선택 카드가 없으면 공개 역할도 없어야 합니다.")
end
elseif not isAsciiId(state.characterIntent.publicRole) then
addError(errors, "missing_public_role", "$.characterIntent.publicRole", "캐릭터 선택의 공개 역할이 필요합니다.")
end
local mainCards = {}
local mainCardIndex = nil
if type(state.characterIntent.cardInstanceIds) == "table" then
for index, instanceId in ipairs(state.characterIntent.cardInstanceIds) do
local instance = instances[instanceId]
if not instance or instance.owner ~= "character" or instance.zone ~= "hand" then
addError(errors, "invalid_intent_card", "$.characterIntent.cardInstanceIds[" .. index .. "]", "캐릭터 선택 카드는 캐릭터 손패에 있어야 합니다.")
else
local cards = type(staticData) == "table" and staticData.cards or nil
local card = type(cards) == "table" and cards[instance.cardId] or nil
if card and card.cardType ~= "chain" then
table.insert(mainCards, card)
mainCardIndex = index
end
end
end
end
if type(staticData) == "table" and intentCount > 0 then
if #mainCards ~= 1 then
addError(errors, "invalid_character_main_action", "$.characterIntent.cardInstanceIds", "캐릭터 선택에는 주 행동 카드가 정확히 하나 있어야 합니다.")
elseif mainCardIndex ~= intentCount then
addError(errors, "character_main_action_not_last", "$.characterIntent.cardInstanceIds", "캐릭터 연계 카드는 주 행동보다 앞에 있어야 합니다.")
elseif mainCards[1].roles[1] ~= state.characterIntent.publicRole then
addError(errors, "public_role_mismatch", "$.characterIntent.publicRole", "공개 역할이 실제 주 행동과 다릅니다.")
end
end
local registry = type(staticData) == "table" and staticData.registry or nil
local roles = type(registry) == "table" and registry.roles or nil
local publicRole = type(roles) == "table" and roles[state.characterIntent.publicRole] or nil
if state.characterIntent.publicRole ~= nil and type(roles) == "table" then
if not publicRole then
addError(errors, "unknown_public_role", "$.characterIntent.publicRole", "등록되지 않은 역할입니다.")
elseif publicRole.owner ~= "character" then
addError(errors, "public_role_owner_mismatch", "$.characterIntent.publicRole", "캐릭터 역할이 아닙니다.")
end
end
end
if type(staticData) == "table" then
if type(state.character) == "table" then
if type(staticData.characters) == "table" and not staticData.characters[state.character.characterId] then
addError(errors, "unknown_character", "$.character.characterId", "정적 DB에서 캐릭터를 찾을 수 없습니다.")
end
if type(staticData.registry) == "table"
and type(staticData.registry.moods) == "table"
and not staticData.registry.moods[state.character.mood] then
addError(errors, "unknown_mood", "$.character.mood", "등록되지 않은 무드입니다.")
end
if type(staticData.traits) == "table" and type(state.character.traitIds) == "table" then
for index, traitId in ipairs(state.character.traitIds) do
if not staticData.traits[traitId] then
addError(errors, "unknown_trait", "$.character.traitIds[" .. index .. "]", "정적 DB에서 특징을 찾을 수 없습니다.")
end
end
end
end
end
local historyReport, historyCallError = callReceiptValidator(
"battleHistory",
"validate",
state.history,
state,
staticData
)
if historyCallError ~= nil then
addError(errors, "history_validation_unavailable", "$.history", "전투 이력 검증을 실행할 수 없습니다: " .. historyCallError)
elseif historyReport.ok ~= true then
appendNestedErrors(errors, "$.history", historyReport)
end
if state.turnStartReceipt ~= nil then
validateTurnStartReceipt(state.turnStartReceipt, state, staticData, referencesValidated, errors)
end
if type(state.player) == "table" and type(state.character) == "table"
and isFinite(state.player.stealth) and isFinite(state.character.resistance) then
if state.character.resistance <= 0 and state.status ~= "victory" then
addError(errors, "victory_priority", "$.status", "저항이 0 이하이면 동시 은폐 소진보다 승리를 우선해야 합니다.")
elseif state.character.resistance > 0 and state.player.stealth <= 0 and state.status ~= "defeat" then
addError(errors, "defeat_required", "$.status", "은폐가 0 이하이면 패배 상태여야 합니다.")
elseif state.status == "active" and (state.character.resistance <= 0 or state.player.stealth <= 0) then
addError(errors, "active_outcome_conflict", "$.status", "종료 수치에서 active 상태를 유지할 수 없습니다.")
elseif state.status == "victory" and state.character.resistance > 0 then
addError(errors, "invalid_victory", "$.status", "저항이 남아 있으면 승리 상태일 수 없습니다.")
elseif state.status == "defeat"
and state.player.stealth > 0
and isInteger(state.turnNumber, 1)
and isInteger(state.turnLimit, 1)
and state.turnNumber < state.turnLimit then
addError(errors, "invalid_defeat", "$.status", "은폐가 남고 제한 턴 전이면 패배 상태일 수 없습니다.")
end
end
local report = result(errors, state)
report.referencesValidated = referencesValidated
return report
end
local function fingerprintBattleState(state, staticData)
local validation = validateBattleState(state, staticData)
if not validation.ok then
return validation
end
if validation.referencesValidated ~= true then
local errors = {}
addError(
errors,
"static_references_not_validated",
"$.staticData",
"battleState fingerprint에는 전체 정적 데이터 참조 검증이 필요합니다."
)
local report = result(errors)
report.referencesValidated = false
return report
end
local authorityFingerprint, fingerprintError = fingerprintAuthorityState(state)
if fingerprintError then
local errors = {}
addError(errors, fingerprintError.code, fingerprintError.path, fingerprintError.message)
local report = result(errors)
report.referencesValidated = true
return report
end
return {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
referencesValidated = true,
fingerprint = authorityFingerprint,
}
end
local function sealTurnStartReceipt(state, staticData)
local errors = {}
if type(state) ~= "table" or type(state.turnStartReceipt) ~= "table" then
addError(errors, "missing_unsealed_turn_receipt", "$.turnStartReceipt", "봉인할 turnStartReceipt가 없습니다.")
return result(errors)
end
if state.turnStartReceipt.authorityFingerprint ~= nil then
addError(errors, "turn_receipt_already_sealed", "$.turnStartReceipt.authorityFingerprint", "이미 authorityFingerprint가 있는 영수증은 다시 봉인할 수 없습니다.")
return result(errors)
end
local authorityFingerprint, fingerprintError = fingerprintAuthorityState(state)
if fingerprintError then
addError(errors, fingerprintError.code, fingerprintError.path, fingerprintError.message)
return result(errors)
end
local function cloneValue(value, path, active)
local valueType = type(value)
if valueType == "nil" or valueType == "string" or valueType == "boolean" then
return value, nil
end
if valueType == "number" then
if not isFinite(value) then
return nil, { code = "non_finite_number", path = path, message = "NaN과 무한대는 봉인 상태에 사용할 수 없습니다." }
end
return value, nil
end
if valueType ~= "table" or getmetatable(value) ~= nil then
return nil, { code = "unsupported_type", path = path, message = "봉인 상태에 사용할 수 없는 값입니다." }
end
active = active or {}
if active[value] then
return nil, { code = "circular_reference", path = path, message = "순환 참조가 있는 상태는 봉인할 수 없습니다." }
end
active[value] = true
local copy = {}
for key, item in pairs(value) do
local itemCopy, itemError = cloneValue(item, objectPath(path, key), active)
if itemError then
active[value] = nil
return nil, itemError
end
copy[key] = itemCopy
end
active[value] = nil
return copy, nil
end
local sealedState, cloneError = cloneValue(state, "$", {})
if cloneError then
addError(errors, cloneError.code, cloneError.path, cloneError.message)
return result(errors)
end
sealedState.turnStartReceipt.authorityFingerprint = authorityFingerprint
local reportFingerprint, reportFingerprintError = cloneValue(
authorityFingerprint,
"$.fingerprint",
{}
)
if reportFingerprintError then
addError(
errors,
reportFingerprintError.code,
reportFingerprintError.path,
reportFingerprintError.message
)
return result(errors)
end
local validation = validateBattleState(sealedState, staticData)
if not validation.ok then
return validation
end
if validation.referencesValidated ~= true then
addError(errors, "static_references_not_validated", "$.staticData", "turnStartReceipt 봉인에는 전체 정적 데이터 참조 검증이 필요합니다.")
local report = result(errors)
report.referencesValidated = false
return report
end
return {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
referencesValidated = true,
state = sealedState,
fingerprint = reportFingerprint,
}
end
local function validateEventEnvelope(value, path, errors)
if type(value) ~= "table" then
addError(errors, "invalid_event_envelope", path, "사건 묶음이 테이블이 아닙니다.")
return
end
checkAllowedKeys(value, { schemaVersion = true, events = true }, path, errors)
if value.schemaVersion ~= SCHEMA_VERSION then
addError(errors, "unsupported_schema", path .. ".schemaVersion", "지원하지 않는 사건 묶음 스키마입니다.")
end
getArrayLength(value.events, path .. ".events", errors)
end
local function arraysEqual(left, right)
if type(left) ~= "table" or type(right) ~= "table" or #left ~= #right then
return false
end
for index = 1, #left do
if left[index] ~= right[index] then
return false
end
end
return true
end
local function dataEqual(left, right, seen)
if type(left) ~= type(right) then return false end
if type(left) ~= "table" then return left == right end
if getmetatable(left) ~= nil or getmetatable(right) ~= nil then return false end
seen = seen or {}
if seen[left] ~= nil then return seen[left] == right end
seen[left] = right
for key, value in pairs(left) do
if not dataEqual(value, right[key], seen) then return false end
end
for key in pairs(right) do
if left[key] == nil then return false end
end
return true
end
local function fingerprintsEqual(left, right)
return type(left) == "table"
and type(right) == "table"
and left.algorithm == right.algorithm
and left.length == right.length
and left.hashA == right.hashA
and left.hashB == right.hashB
end
local function validateDraftFingerprint(value, path, errors)
if type(value) ~= "table" then
addError(errors, "invalid_projection_fingerprint", path, "projection 원본 fingerprint가 객체가 아닙니다.")
return false
end
checkAllowedKeys(value, {
algorithm = true,
length = true,
hashA = true,
hashB = true,
}, path, errors)
local valid = true
if value.algorithm ~= DRAFT_FINGERPRINT_ALGORITHM then
addError(errors, "invalid_projection_fingerprint_algorithm", path .. ".algorithm", "지원하지 않는 projection 원본 fingerprint 알고리즘입니다.")
valid = false
end
for _, field in ipairs({ "length", "hashA", "hashB" }) do
if not isSafeInteger(value[field], 0) then
addError(errors, "invalid_projection_fingerprint", path .. "." .. field, "projection 원본 fingerprint 수치는 0 이상의 안전한 정수여야 합니다.")
valid = false
end
end
return valid
end
local function validatePendingIntegrity(pending, errors)
local path = "$.integrity"
local value = pending.integrity
if type(value) ~= "table" then
addError(errors, "missing_pending_integrity", path, "pendingTurn에는 전체 저장값 무결성 영수증이 필요합니다.")
return
end
checkAllowedKeys(value, {
algorithm = true,
length = true,
hashA = true,
hashB = true,
}, path, errors)
local valid = true
if value.algorithm ~= PENDING_INTEGRITY_ALGORITHM then
addError(errors, "invalid_pending_integrity_algorithm", path .. ".algorithm", "지원하지 않는 pendingTurn 무결성 알고리즘입니다.")
valid = false
end
for _, field in ipairs({ "length", "hashA", "hashB" }) do
if not isSafeInteger(value[field], 0) then
addError(errors, "invalid_pending_integrity", path .. "." .. field, "pendingTurn 무결성 수치는 0 이상의 안전한 정수여야 합니다.")
valid = false
end
end
if not valid then
return
end
local expected, fingerprintError = fingerprintPendingTurn(pending)
if fingerprintError then
addError(errors, fingerprintError.code, fingerprintError.path, fingerprintError.message)
elseif not fingerprintsEqual(value, expected) then
addError(errors, "pending_integrity_mismatch", path, "pendingTurn 저장값이 생성 뒤 변경되었습니다.")
end
end
local function validateProjectionReceiptShape(receipt, path, errors)
if type(receipt) ~= "table" then
addError(errors, "invalid_projection_receipt", path, "projectionReceipt가 객체가 아닙니다.")
return
end
checkAllowedKeys(receipt, {
schemaVersion = true,
kind = true,
mode = true,
selectedCardInstanceIds = true,
effectChoiceByInstanceId = true,
source = true,
projectedRng = true,
}, path, errors)
if receipt.schemaVersion ~= SCHEMA_VERSION then
addError(errors, "unsupported_projection_receipt_schema", path .. ".schemaVersion", "지원하지 않는 projectionReceipt 스키마입니다.")
end
if receipt.kind ~= "turnDraftProjectionReceipt" then
addError(errors, "invalid_projection_receipt_kind", path .. ".kind", "kind는 turnDraftProjectionReceipt여야 합니다.")
end
if receipt.mode ~= "pass" and receipt.mode ~= "chain_pass" and receipt.mode ~= "action" then
addError(errors, "invalid_projection_mode", path .. ".mode", "알 수 없는 projection mode입니다.")
end
validateIdArray(receipt.selectedCardInstanceIds, path .. ".selectedCardInstanceIds", errors, isRuntimeId)
if receipt.effectChoiceByInstanceId ~= nil and type(receipt.effectChoiceByInstanceId) ~= "table" then
addError(errors, "invalid_effect_choice_map", path .. ".effectChoiceByInstanceId", "효과 선택값은 객체여야 합니다.")
else
local selected = {}
for _, instanceId in ipairs(type(receipt.selectedCardInstanceIds) == "table" and receipt.selectedCardInstanceIds or {}) do
selected[instanceId] = true
end
for instanceId, choiceId in pairs(receipt.effectChoiceByInstanceId or {}) do
if not isRuntimeId(instanceId) or not selected[instanceId]
or not isAsciiId(choiceId) then
addError(errors, "invalid_effect_choice", path .. ".effectChoiceByInstanceId." .. tostring(instanceId), "봉인된 효과 선택값이 올바르지 않습니다.")
end
end
end
local selectedCount = type(receipt.selectedCardInstanceIds) == "table" and #receipt.selectedCardInstanceIds or 0
if receipt.mode == "pass" and selectedCount ~= 0 then
addError(errors, "projection_pass_has_selection", path .. ".selectedCardInstanceIds", "pass projection에는 등록 카드가 없어야 합니다.")
elseif (receipt.mode == "chain_pass" or receipt.mode == "action") and selectedCount == 0 then
addError(errors, "projection_mode_missing_selection", path .. ".selectedCardInstanceIds", "카드가 있는 projection mode에는 등록 카드가 필요합니다.")
end
local source = receipt.source
if type(source) ~= "table" then
addError(errors, "invalid_projection_source", path .. ".source", "projection 원본 표식이 객체가 아닙니다.")
else
checkAllowedKeys(source, {
battleId = true,
status = true,
turnNumber = true,
lastCommittedTurnId = true,
rng = true,
fingerprint = true,
}, path .. ".source", errors)
if not isRuntimeId(source.battleId) then
addError(errors, "invalid_battle_id", path .. ".source.battleId", "projection 원본 battleId가 올바르지 않습니다.")
end
if source.status ~= "active" then
addError(errors, "invalid_projection_source_status", path .. ".source.status", "projection 원본 상태는 active여야 합니다.")
end
if not isInteger(source.turnNumber, 1) then
addError(errors, "invalid_turn_number", path .. ".source.turnNumber", "projection 원본 턴 번호가 올바르지 않습니다.")
end
if source.lastCommittedTurnId ~= nil and not isRuntimeId(source.lastCommittedTurnId) then
addError(errors, "invalid_turn_id", path .. ".source.lastCommittedTurnId", "projection 원본 마지막 확정 턴 ID가 올바르지 않습니다.")
end
validateReceiptRng(source.rng, path .. ".source.rng", errors)
validateDraftFingerprint(source.fingerprint, path .. ".source.fingerprint", errors)
end
validateReceiptRng(receipt.projectedRng, path .. ".projectedRng", errors)
if type(source) == "table" and type(source.rng) == "table" and type(receipt.projectedRng) == "table" then
if receipt.projectedRng.seed ~= source.rng.seed then
addError(errors, "projection_rng_seed_changed", path .. ".projectedRng.seed", "projection 중 RNG seed가 바뀔 수 없습니다.")
end
if isInteger(source.rng.cursor, 0)
and isInteger(receipt.projectedRng.cursor, 0)
and receipt.projectedRng.cursor < source.rng.cursor then
addError(errors, "projection_rng_reversed", path .. ".projectedRng.cursor", "projection RNG cursor가 이전으로 돌아갈 수 없습니다.")
end
end
end
local function validatePendingTurn(pending, staticData)
local errors = {}
local staticDataProvided = staticData ~= nil
staticData = normalizeStaticData(staticData)
local referencesValidated = hasCompleteStaticData(staticData)
if staticDataProvided and not referencesValidated then
addError(errors, "invalid_static_data", "$", "전달한 정적 데이터가 전체 참조 검증에 필요한 컬렉션을 갖추지 못했습니다.")
end
if not referencesValidated then
staticData = nil
end
if type(pending) ~= "table" then
addError(errors, "invalid_pending_turn", "$", "pendingTurn이 테이블이 아닙니다.")
return result(errors)
end
validateJsonSafe(pending, "$", errors)
if #errors > 0 then
local report = result(errors)
report.referencesValidated = referencesValidated
return report
end
checkAllowedKeys(pending, {
schemaVersion = true,
kind = true,
battleId = true,
turnId = true,
status = true,
beforeState = true,
projectionReceipt = true,
selectedCards = true,
turnResult = true,
afterState = true,
integrity = true,
}, "$", errors)
if pending.schemaVersion ~= SCHEMA_VERSION then
addError(errors, "unsupported_schema", "$.schemaVersion", "지원하지 않는 pendingTurn 스키마 버전입니다.")
end
if pending.kind ~= "pendingTurn" then
addError(errors, "invalid_kind", "$.kind", "kind는 pendingTurn이어야 합니다.")
end
if not isRuntimeId(pending.battleId) then
addError(errors, "invalid_battle_id", "$.battleId", "battleId가 올바르지 않습니다.")
end
if not isRuntimeId(pending.turnId) then
addError(errors, "invalid_turn_id", "$.turnId", "turnId가 올바르지 않습니다.")
end
if pending.status ~= "awaitingOutput" then
addError(errors, "invalid_pending_status", "$.status", "대기 트랜잭션 상태는 awaitingOutput이어야 합니다.")
end
local beforeResult = validateBattleState(pending.beforeState, staticData)
if not beforeResult.ok then
appendNestedErrors(errors, "$.beforeState", beforeResult)
end
local afterResult = validateBattleState(pending.afterState, staticData)
if not afterResult.ok then
appendNestedErrors(errors, "$.afterState", afterResult)
end
validateProjectionReceiptShape(pending.projectionReceipt, "$.projectionReceipt", errors)
if type(pending.selectedCards) ~= "table" then
addError(errors, "invalid_selected_cards", "$.selectedCards", "selectedCards가 테이블이 아닙니다.")
else
checkAllowedKeys(pending.selectedCards, { player = true, character = true }, "$.selectedCards", errors)
validateIdArray(pending.selectedCards.player, "$.selectedCards.player", errors, isRuntimeId)
validateIdArray(pending.selectedCards.character, "$.selectedCards.character", errors, isRuntimeId)
end
if type(pending.turnResult) ~= "table" then
addError(errors, "invalid_turn_result", "$.turnResult", "turnResult가 테이블이 아닙니다.")
else
checkAllowedKeys(pending.turnResult, {
events = true,
publicResult = true,
llmEvent = true,
}, "$.turnResult", errors)
getArrayLength(pending.turnResult.events, "$.turnResult.events", errors)
validateEventEnvelope(pending.turnResult.publicResult, "$.turnResult.publicResult", errors)
validateEventEnvelope(pending.turnResult.llmEvent, "$.turnResult.llmEvent", errors)
end
if type(pending.turnResult) == "table" and type(pending.afterState) == "table" then
local history = pending.afterState.history
local turns = type(history) == "table" and history.turns or nil
local lastEntry = type(turns) == "table" and turns[#turns] or nil
if type(lastEntry) ~= "table" or lastEntry.turnId ~= pending.turnId then
addError(errors, "pending_history_turn_mismatch", "$.afterState.history.turns", "afterState 전투 이력의 마지막 턴이 pendingTurn과 다릅니다.")
elseif not dataEqual(lastEntry.publicResult, pending.turnResult.publicResult) then
addError(errors, "pending_history_public_result_mismatch", "$.afterState.history.turns", "전투 이력의 공개 로그와 pendingTurn 공개 결과가 다릅니다.")
end
end
if type(pending.beforeState) == "table" and type(pending.afterState) == "table" then
if pending.beforeState.battleId ~= pending.battleId then
addError(errors, "before_battle_mismatch", "$.beforeState.battleId", "beforeState의 전투 ID가 다릅니다.")
end
if pending.afterState.battleId ~= pending.battleId then
addError(errors, "after_battle_mismatch", "$.afterState.battleId", "afterState의 전투 ID가 다릅니다.")
end
if pending.beforeState.lastCommittedTurnId == pending.turnId then
addError(errors, "turn_already_committed", "$.beforeState.lastCommittedTurnId", "beforeState에 이미 같은 턴이 반영되었습니다.")
end
if pending.afterState.lastCommittedTurnId ~= pending.turnId then
addError(errors, "after_turn_not_marked", "$.afterState.lastCommittedTurnId", "afterState에 대기 턴 ID가 기록되어야 합니다.")
end
if pending.beforeState.status ~= "active" then
addError(errors, "before_state_not_active", "$.beforeState.status", "종료된 전투에서 새 대기 턴을 만들 수 없습니다.")
end
local beforeSelection = type(pending.beforeState.selection) == "table"
and pending.beforeState.selection.playerCardInstanceIds
or nil
if type(beforeSelection) ~= "table" or #beforeSelection ~= 0 then
addError(errors, "pending_authority_selection_not_empty", "$.beforeState.selection.playerCardInstanceIds", "pendingTurn의 권위 beforeState에는 플레이어 드래프트 선택을 저장할 수 없습니다.")
end
local startReceipt = pending.beforeState.turnStartReceipt
if type(startReceipt) ~= "table" then
addError(errors, "pending_missing_turn_start_receipt", "$.beforeState.turnStartReceipt", "pendingTurn beforeState에는 봉인된 turnStartReceipt가 필요합니다.")
elseif startReceipt.turnId ~= pending.turnId then
addError(errors, "pending_turn_id_mismatch", "$.turnId", "pendingTurn turnId가 beforeState.turnStartReceipt와 다릅니다.")
end
if pending.afterState.turnStartReceipt ~= nil then
addError(errors, "pending_after_turn_receipt_present", "$.afterState.turnStartReceipt", "해결된 afterState에는 turnStartReceipt를 남길 수 없습니다.")
end
local afterSelection = type(pending.afterState.selection) == "table"
and pending.afterState.selection.playerCardInstanceIds
or nil
if type(afterSelection) ~= "table" or #afterSelection ~= 0 then
addError(errors, "pending_after_selection_not_empty", "$.afterState.selection.playerCardInstanceIds", "해결된 afterState에는 플레이어 선택을 남길 수 없습니다.")
end
local afterIntent = pending.afterState.characterIntent
local afterIntentIds = type(afterIntent) == "table" and afterIntent.cardInstanceIds or nil
if type(afterIntentIds) ~= "table" or #afterIntentIds ~= 0 then
addError(errors, "pending_after_character_intent_not_empty", "$.afterState.characterIntent.cardInstanceIds", "해결된 afterState에는 캐릭터 선택을 남길 수 없습니다.")
end
if type(afterIntent) == "table" and afterIntent.publicRole ~= nil then
addError(errors, "pending_after_public_role_present", "$.afterState.characterIntent.publicRole", "해결된 afterState에는 공개 역할을 남길 수 없습니다.")
end
if isInteger(pending.beforeState.turnNumber, 1)
and isInteger(pending.afterState.turnNumber, 1)
and pending.afterState.turnNumber < pending.beforeState.turnNumber then
addError(errors, "turn_number_reversed", "$.afterState.turnNumber", "afterState의 턴 번호가 이전으로 돌아갈 수 없습니다.")
elseif isInteger(pending.beforeState.turnNumber, 1)
and isInteger(pending.afterState.turnNumber, 1)
and pending.afterState.turnNumber > pending.beforeState.turnNumber + 1 then
addError(errors, "turn_number_skipped", "$.afterState.turnNumber", "한 번의 대기 트랜잭션에서 턴을 둘 이상 건너뛸 수 없습니다.")
end
if pending.afterState.turnLimit ~= pending.beforeState.turnLimit then
addError(errors, "turn_limit_changed", "$.afterState.turnLimit", "한 턴 판정 중 제한 턴을 바꿀 수 없습니다.")
end
if not sceneContextEqual(pending.afterState.sceneContext, pending.beforeState.sceneContext) then
addError(errors, "scene_context_changed", "$.afterState.sceneContext", "한 턴 판정 중 배경 정보를 바꿀 수 없습니다.")
end
if not transitEqual(pending.afterState.transit, pending.beforeState.transit) then
addError(errors, "transit_changed", "$.afterState.transit", "한 턴 판정 중 지하철 이동 구간을 바꿀 수 없습니다.")
end
local beforeCharacter = type(pending.beforeState.character) == "table" and pending.beforeState.character.characterId or nil
local afterCharacter = type(pending.afterState.character) == "table" and pending.afterState.character.characterId or nil
if afterCharacter ~= beforeCharacter then
addError(errors, "character_changed", "$.afterState.character.characterId", "한 전투의 캐릭터를 턴 판정 중 바꿀 수 없습니다.")
end
local beforeRng = pending.beforeState.rng
local afterRng = pending.afterState.rng
if type(beforeRng) == "table" and type(afterRng) == "table" then
if afterRng.seed ~= beforeRng.seed then
addError(errors, "rng_seed_changed", "$.afterState.rng.seed", "한 전투의 난수 시드를 턴 판정 중 바꿀 수 없습니다.")
end
if isInteger(beforeRng.cursor, 0)
and isInteger(afterRng.cursor, 0)
and afterRng.cursor < beforeRng.cursor then
addError(errors, "rng_cursor_reversed", "$.afterState.rng.cursor", "난수 커서는 이전으로 돌아갈 수 없습니다.")
end
end
local projectionReceipt = pending.projectionReceipt
if type(projectionReceipt) == "table" then
local source = projectionReceipt.source
if type(source) == "table" then
if source.battleId ~= pending.battleId
or source.battleId ~= pending.beforeState.battleId then
addError(errors, "projection_battle_mismatch", "$.projectionReceipt.source.battleId", "projectionReceipt battleId가 pendingTurn 권위 상태와 다릅니다.")
end
if source.status ~= pending.beforeState.status then
addError(errors, "projection_status_mismatch", "$.projectionReceipt.source.status", "projectionReceipt 상태가 beforeState와 다릅니다.")
end
if source.turnNumber ~= pending.beforeState.turnNumber then
addError(errors, "projection_turn_mismatch", "$.projectionReceipt.source.turnNumber", "projectionReceipt 턴 번호가 beforeState와 다릅니다.")
end
if source.lastCommittedTurnId ~= pending.beforeState.lastCommittedTurnId then
addError(errors, "projection_commit_mismatch", "$.projectionReceipt.source.lastCommittedTurnId", "projectionReceipt 마지막 확정 턴 ID가 beforeState와 다릅니다.")
end
if not rngEqual(source.rng, pending.beforeState.rng) then
addError(errors, "projection_rng_mismatch", "$.projectionReceipt.source.rng", "projectionReceipt 시작 RNG가 beforeState와 다릅니다.")
end
local expectedFingerprint, fingerprintError = fingerprintDraftAuthorityState(pending.beforeState)
if fingerprintError then
addError(errors, fingerprintError.code, fingerprintError.path, fingerprintError.message)
elseif not fingerprintsEqual(source.fingerprint, expectedFingerprint) then
addError(errors, "projection_fingerprint_mismatch", "$.projectionReceipt.source.fingerprint", "projectionReceipt fingerprint가 beforeState 전체와 다릅니다.")
end
end
local projectedRng = projectionReceipt.projectedRng
if type(projectedRng) == "table"
and type(afterRng) == "table"
and isInteger(projectedRng.cursor, 0)
and isInteger(afterRng.cursor, 0)
and afterRng.cursor < projectedRng.cursor then
addError(errors, "after_rng_before_projection", "$.afterState.rng.cursor", "afterState RNG cursor는 선택 projection이 소비한 위치보다 이전일 수 없습니다.")
end
end
if type(pending.selectedCards) == "table" then
local selected = type(projectionReceipt) == "table"
and projectionReceipt.selectedCardInstanceIds
or nil
local intent = type(pending.beforeState.characterIntent) == "table"
and pending.beforeState.characterIntent.cardInstanceIds
or nil
if not arraysEqual(pending.selectedCards.player, selected) then
addError(errors, "player_selection_mismatch", "$.selectedCards.player", "projectionReceipt의 플레이어 선택과 다릅니다.")
end
if not arraysEqual(pending.selectedCards.character, intent) then
addError(errors, "character_selection_mismatch", "$.selectedCards.character", "beforeState의 캐릭터 선택과 다릅니다.")
end
end
end
validatePendingIntegrity(pending, errors)
local report = result(errors, pending)
report.referencesValidated = referencesValidated
report.projectionReplayValidated = false
return report
end
local function cloneJson(value, active)
local valueType = type(value)
if valueType ~= "table" then
if valueType == "string" or valueType == "number" or valueType == "boolean" or valueType == "nil" then
return value
end
error("unsupported type: " .. valueType)
end
if getmetatable(value) ~= nil then
error("metatable is not allowed")
end
active = active or {}
if active[value] then
error("circular reference")
end
active[value] = true
local copy = {}
for key, item in pairs(value) do
copy[cloneJson(key, active)] = cloneJson(item, active)
end
active[value] = nil
return copy
end
local function constructBattleState(options, staticData)
if type(options) ~= "table" then
local errors = {}
addError(errors, "invalid_options", "$", "battleState 생성 사양이 테이블이 아닙니다.")
return result(errors)
end
local ok, state = pcall(function()
local value = cloneJson(options)
value.schemaVersion = SCHEMA_VERSION
value.kind = "battleState"
local normalizedStaticData = normalizeStaticData(staticData)
if type(value.character) == "table" and value.character.moodTokens == nil
and type(normalizedStaticData) == "table"
and type(normalizedStaticData.registry) == "table"
and type(normalizedStaticData.registry.moods) == "table" then
value.character.moodTokens = {}
for moodId in pairs(normalizedStaticData.registry.moods) do
value.character.moodTokens[moodId] = 0
end
end
if value.history == nil then
value.history = {
schemaVersion = SCHEMA_VERSION,
kind = "battleHistory",
turns = {},
}
end
return value
end)
if not ok then
local errors = {}
addError(errors, "construct_failed", "$", "battleState 생성에 실패했습니다: " .. tostring(state))
return result(errors)
end
local validation = validateBattleState(state, staticData)
if not validation.ok then
return validation
end
local report = result({}, state)
report.referencesValidated = validation.referencesValidated
return report
end
local function constructPendingTurn(spec, staticData)
if type(spec) ~= "table" then
local errors = {}
addError(errors, "invalid_spec", "$", "pendingTurn 생성 사양이 테이블이 아닙니다.")
return result(errors)
end
local ok, pending = pcall(function()
local value = cloneJson(spec)
value.schemaVersion = SCHEMA_VERSION
value.kind = "pendingTurn"
value.status = "awaitingOutput"
value.integrity = nil
return value
end)
if not ok then
local errors = {}
addError(errors, "construct_failed", "$", "pendingTurn 생성에 실패했습니다: " .. tostring(pending))
return result(errors)
end
local integrity, integrityError = fingerprintPendingTurn(pending)
if integrityError then
local errors = {}
addError(errors, integrityError.code, integrityError.path, integrityError.message)
return result(errors)
end
pending.integrity = integrity
local validation = validatePendingTurn(pending, staticData)
if not validation.ok then
return validation
end
local report = result({}, pending)
report.referencesValidated = validation.referencesValidated
report.projectionReplayValidated = false
return report
end
local arguments = { ... }
if action == "newBattleState" then
return constructBattleState(arguments[1], arguments[2])
elseif action == "validateBattleState" then
return validateBattleState(arguments[1], arguments[2])
elseif action == "fingerprintBattleState" then
return fingerprintBattleState(arguments[1], arguments[2])
elseif action == "sealTurnStartReceipt" then
return sealTurnStartReceipt(arguments[1], arguments[2])
elseif action == "newPendingTurn" then
return constructPendingTurn(arguments[1], arguments[2])
elseif action == "validatePendingTurn" then
return validatePendingTurn(arguments[1], arguments[2])
end
local errors = {}
addError(errors, "unknown_action", "$", "지원하지 않는 상태 스키마 작업입니다: " .. tostring(action))
return result(errors)
end)
#48staticData.lua
Keys:
(function()
local STATIC_CACHE_MAX_ENTRIES = 4
local MAX_PLAN_CAPACITY = 16
local DIAGNOSTIC_SCOPE = "helltrain.staticData"
local STATIC_BASE_LORE_ORDER = {
"GameRegistry.db",
"PlayerCards.db",
"CharacterCards.db",
"CharTraits.db",
"TokyoSubwayLines.db",
"CharacterList.db",
}
local staticCacheEntries = {}
local staticCacheClock = 0
local staticCacheStats = {
requests = 0,
captures = 0,
fastHits = 0,
hits = 0,
misses = 0,
validations = 0,
failedValidations = 0,
evictions = 0,
clears = 0,
}
-- 호스트 진단 출력은 print 한 줄로 통일한다. DB 본문이나 프로필은 남기지
-- 않고 로어 이름, 개수, 헤더와 오류 경로만 기록한다.
local function diagnosticsEnabled(depth)
return type(DEBUG) == "number" and depth <= DEBUG
end
local function diagnosticText(value)
return tostring(value):gsub("[\r\n]+", " ")
end
local function emitDiagnostic(depth, event, fields)
if not diagnosticsEnabled(depth) then
return
end
if type(print) ~= "function" then
return
end
local parts = {
"[" .. DIAGNOSTIC_SCOPE .. "]",
"depth=" .. tostring(depth),
"event=" .. diagnosticText(event),
}
local fieldOrder = {
"action", "forceRefresh", "cachedEntries", "cache", "ok",
"lore", "status", "entryCount", "error", "entryIndex",
"schemaVersion", "kindType", "kind", "code", "path", "message",
"expectedKind", "actualKindType", "actualKind", "errorCount",
"cards", "traits", "subwayLines", "characters",
}
for _, key in ipairs(fieldOrder) do
local value = type(fields) == "table" and fields[key] or nil
if value ~= nil then
parts[#parts + 1] = key .. "=" .. diagnosticText(value)
end
end
pcall(print, table.concat(parts, " | "))
end
-- 캐시의 canonical snapshot을 호출자에게 직접 노출하지 않는다. 함수는 정적
-- DB callback이므로 identity를 유지하고, 모든 table은 alias가 없도록 복제한다.
local function cloneStaticValue(value, seen)
if type(value) ~= "table" then
return value
end
seen = seen or {}
if seen[value] ~= nil then
return seen[value]
end
local copy = {}
seen[value] = copy
for key, item in pairs(value) do
copy[cloneStaticValue(key, seen)] = cloneStaticValue(item, seen)
end
return copy
end
-- getLoreBooks 결과의 metadata는 DB 판정에 사용하지 않는다. 개발/강제 refresh
-- 경로에서는 content와 순서 전체를 비교해 context 오염과 stale hit를 막는다.
-- production fast path의 배포 무효화는 main의 RUNTIME_BUNDLE_REVISION 계약이다.
local function captureStaticLore(triggerId, captured, loreName)
if captured[loreName] ~= nil then
return
end
local ok, lores = pcall(getLoreBooks, triggerId, loreName)
local source = {
status = ok and type(lores) or "read_error",
entries = {},
}
if not ok then
source.error = tostring(lores)
elseif type(lores) == "table" then
for _, lore in ipairs(lores) do
if type(lore) == "table" and type(lore.content) == "string" then
table.insert(source.entries, {
valid = true,
content = lore.content,
})
else
table.insert(source.entries, {
valid = false,
loreType = type(lore),
contentType = type(lore) == "table" and type(lore.content) or "none",
})
end
end
end
captured[loreName] = source
table.insert(captured.order, loreName)
emitDiagnostic(source.status == "read_error" and 1 or 2, "lore_capture", {
lore = loreName,
status = source.status,
entryCount = #source.entries,
error = source.error,
})
end
local function captureBaseStaticLores(triggerId)
staticCacheStats.captures = staticCacheStats.captures + 1
local captured = {
order = {},
}
for _, loreName in ipairs(STATIC_BASE_LORE_ORDER) do
captureStaticLore(triggerId, captured, loreName)
end
return captured
end
local function sameStaticLoreSource(leftSource, rightSource)
if leftSource == nil
or rightSource == nil
or leftSource.status ~= rightSource.status
or leftSource.error ~= rightSource.error
or #leftSource.entries ~= #rightSource.entries then
return false
end
for index, leftEntry in ipairs(leftSource.entries) do
local rightEntry = rightSource.entries[index]
if rightEntry == nil
or leftEntry.valid ~= rightEntry.valid
or leftEntry.content ~= rightEntry.content
or leftEntry.loreType ~= rightEntry.loreType
or leftEntry.contentType ~= rightEntry.contentType then
return false
end
end
return true
end
local function sameStaticLoreCapture(left, right)
if type(left.order) ~= "table"
or type(right.order) ~= "table"
or #left.order ~= #right.order then
return false
end
for index, loreName in ipairs(left.order) do
if right.order[index] ~= loreName
or not sameStaticLoreSource(left[loreName], right[loreName]) then
return false
end
end
return true
end
local function findCachedDynamicLoreOrder(captured)
for _, entry in ipairs(staticCacheEntries) do
local sameBase = true
for _, loreName in ipairs(STATIC_BASE_LORE_ORDER) do
if not sameStaticLoreSource(entry.sources[loreName], captured[loreName]) then
sameBase = false
break
end
end
if sameBase then
local dynamicOrder = {}
for index = #STATIC_BASE_LORE_ORDER + 1, #entry.sources.order do
table.insert(dynamicOrder, entry.sources.order[index])
end
return dynamicOrder
end
end
return nil
end
local function findStaticCacheEntry(captured)
for _, entry in ipairs(staticCacheEntries) do
if sameStaticLoreCapture(entry.sources, captured) then
staticCacheClock = staticCacheClock + 1
entry.lastUsed = staticCacheClock
entry.hits = entry.hits + 1
return entry
end
end
return nil
end
local function storeStaticCacheEntry(captured, report)
if #staticCacheEntries >= STATIC_CACHE_MAX_ENTRIES then
local oldestIndex = 1
for index = 2, #staticCacheEntries do
if staticCacheEntries[index].lastUsed < staticCacheEntries[oldestIndex].lastUsed then
oldestIndex = index
end
end
table.remove(staticCacheEntries, oldestIndex)
staticCacheStats.evictions = staticCacheStats.evictions + 1
end
staticCacheClock = staticCacheClock + 1
table.insert(staticCacheEntries, {
sources = captured,
report = report,
lastUsed = staticCacheClock,
hits = 0,
})
end
local function clearStaticCache()
local removed = #staticCacheEntries
staticCacheEntries = {}
staticCacheStats.clears = staticCacheStats.clears + 1
return removed
end
local function getStaticCacheDiagnostics()
local diagnostics = {
maxEntries = STATIC_CACHE_MAX_ENTRIES,
entries = #staticCacheEntries,
requests = staticCacheStats.requests,
captures = staticCacheStats.captures,
fastHits = staticCacheStats.fastHits,
hits = staticCacheStats.hits,
misses = staticCacheStats.misses,
validations = staticCacheStats.validations,
failedValidations = staticCacheStats.failedValidations,
evictions = staticCacheStats.evictions,
clears = staticCacheStats.clears,
cached = {},
}
for _, entry in ipairs(staticCacheEntries) do
table.insert(diagnostics.cached, {
hits = entry.hits,
lastUsed = entry.lastUsed,
counts = cloneStaticValue(entry.report.counts),
})
end
return diagnostics
end
return function(triggerId, action)
local SUPPORTED_SCHEMA_VERSION = 1
local SOURCES = {
registry = {
kind = "gameRegistry",
collection = nil,
lores = { "GameRegistry.db" },
},
cards = {
kind = "cardDatabase",
collection = "cards",
lores = { "PlayerCards.db", "CharacterCards.db" },
},
traits = {
kind = "traitDatabase",
collection = "traits",
lores = { "CharTraits.db" },
},
subwayLines = {
kind = "subwayLineDatabase",
collection = "subwayLines",
lores = { "TokyoSubwayLines.db" },
},
characterList = {
kind = "characterList",
collection = "characters",
lores = { "CharacterList.db" },
},
}
local RESERVED_CHARACTER_DATABASES = {
["GameRegistry.db"] = true,
["PlayerCards.db"] = true,
["CharacterCards.db"] = true,
["CharTraits.db"] = true,
["TokyoSubwayLines.db"] = true,
["CharacterList.db"] = true,
}
local function addError(errors, code, path, message)
table.insert(errors, {
code = code,
path = path,
message = message,
})
emitDiagnostic(1, "validation_error", {
code = code,
path = path,
message = message,
})
end
local function countEntries(value)
if type(value) ~= "table" then
return 0
end
local count = 0
for _ in pairs(value) do
count = count + 1
end
return count
end
local function isArray(value)
if type(value) ~= "table" then
return false
end
local count = 0
local maximum = 0
for key in pairs(value) do
if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then
return false
end
count = count + 1
if key > maximum then
maximum = key
end
end
return count == maximum
end
local function isFiniteNonNegative(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
and value >= 0
end
local function isPositiveInteger(value)
return isFiniteNonNegative(value)
and value >= 1
and value % 1 == 0
end
local function isFiniteInteger(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
and value % 1 == 0
end
local function isPlanCapacity(value)
return isPositiveInteger(value) and value <= MAX_PLAN_CAPACITY
end
local function isAsciiId(value)
return type(value) == "string"
and string.match(value, "^[a-z][a-z0-9_]*$") ~= nil
end
local function isCharacterDatabaseName(value)
return type(value) == "string"
and string.match(value, "^[A-Za-z][A-Za-z0-9_]*%.db$") ~= nil
end
local function makeDatabaseEnvironment()
return {
ipairs = ipairs,
next = next,
pairs = pairs,
tonumber = tonumber,
tostring = tostring,
type = type,
}
end
local function loadLoreModules(loreName, errors, captured)
local source = captured[loreName]
if source == nil or source.status == "read_error" then
addError(
errors,
"lore_read_error",
loreName,
source and source.error or "정적 DB 로어북을 읽을 수 없습니다."
)
return {}
end
if source.status ~= "table" or #source.entries == 0 then
addError(errors, "missing_lore", loreName, "정적 DB 로어북을 찾을 수 없습니다.")
return {}
end
local modules = {}
for index, lore in ipairs(source.entries) do
if lore.valid ~= true then
addError(errors, "invalid_lore", loreName .. "[" .. index .. "]", "로어북 내용이 문자열이 아닙니다.")
else
local chunk, compileError = load(
lore.content,
"static_db:" .. loreName .. ":" .. index,
"t",
makeDatabaseEnvironment()
)
if not chunk then
addError(errors, "compile_error", loreName .. "[" .. index .. "]", tostring(compileError))
else
local ok, result = pcall(chunk)
if not ok then
addError(errors, "load_error", loreName .. "[" .. index .. "]", tostring(result))
elseif type(result) ~= "table" then
addError(errors, "invalid_module", loreName .. "[" .. index .. "]", "DB 청크가 테이블을 반환하지 않았습니다.")
else
table.insert(modules, result)
emitDiagnostic(2, "module_loaded", {
lore = loreName,
entryIndex = index,
schemaVersion = tostring(result.schemaVersion),
kindType = type(result.kind),
kind = type(result.kind) == "string" and result.kind or tostring(result.kind),
})
end
end
end
end
return modules
end
local function validateModuleHeader(module, expectedKind, path, errors)
if module.schemaVersion ~= SUPPORTED_SCHEMA_VERSION then
addError(errors, "unsupported_schema", path .. ".schemaVersion", "지원하지 않는 스키마 버전입니다.")
end
if module.kind ~= expectedKind then
local actualKind = type(module.kind) == "string" and module.kind or tostring(module.kind)
emitDiagnostic(1, "module_header_mismatch", {
path = path .. ".kind",
expectedKind = expectedKind,
actualKindType = type(module.kind),
actualKind = actualKind,
})
addError(
errors,
"unexpected_kind",
path .. ".kind",
"예상한 DB 종류와 다릅니다. (expected="
.. tostring(expectedKind)
.. ", actual="
.. actualKind
.. ")"
)
end
end
local function loadSingleModule(source, errors, captured)
local loaded = nil
for _, loreName in ipairs(source.lores) do
local modules = loadLoreModules(loreName, errors, captured)
for index, module in ipairs(modules) do
local path = loreName .. "[" .. index .. "]"
validateModuleHeader(module, source.kind, path, errors)
if loaded then
addError(errors, "duplicate_module", path, "단일 DB가 여러 번 등록되었습니다.")
else
loaded = module
end
end
end
return loaded
end
local function loadMergedCollection(source, errors, captured)
local merged = {}
local origins = {}
for _, loreName in ipairs(source.lores) do
local modules = loadLoreModules(loreName, errors, captured)
for index, module in ipairs(modules) do
local modulePath = loreName .. "[" .. index .. "]"
validateModuleHeader(module, source.kind, modulePath, errors)
local collection = module[source.collection]
if type(collection) ~= "table" then
addError(errors, "missing_collection", modulePath .. "." .. source.collection, "DB 컬렉션이 없습니다.")
else
for id, definition in pairs(collection) do
local itemPath = modulePath .. "." .. source.collection .. "." .. tostring(id)
if merged[id] ~= nil then
addError(
errors,
"duplicate_id",
itemPath,
"이미 " .. tostring(origins[id]) .. "에서 정의된 ID입니다."
)
else
merged[id] = definition
origins[id] = itemPath
end
end
end
end
end
return merged
end
local function sortedAsciiKeys(collection)
local keys = {}
if type(collection) == "table" then
for key in pairs(collection) do
if isAsciiId(key) then
table.insert(keys, key)
end
end
end
table.sort(keys)
return keys
end
local function collectCharacterDatabaseNames(characterList)
local names = {}
local seen = {}
for _, characterId in ipairs(sortedAsciiKeys(characterList)) do
local entry = characterList[characterId]
local database = type(entry) == "table" and entry.database or nil
if isCharacterDatabaseName(database)
and RESERVED_CHARACTER_DATABASES[database] ~= true
and seen[database] ~= true then
seen[database] = true
table.insert(names, database)
end
end
return names
end
local function validateCharacterList(characterList, errors)
if type(characterList) ~= "table" then
addError(errors, "missing_character_list", "characterList", "캐릭터 목록이 없습니다.")
return
end
if next(characterList) == nil then
addError(errors, "empty_character_list", "characterList", "캐릭터 목록이 비어 있습니다.")
return
end
local firstCharacterByDatabase = {}
for key, entry in pairs(characterList) do
local path = "characterList." .. tostring(key)
if not isAsciiId(key) or type(entry) ~= "table" or entry.id ~= key then
addError(errors, "invalid_character_list_id", path, "캐릭터 목록 키와 내부 ID가 올바르지 않습니다.")
else
for field in pairs(entry) do
if field ~= "id" and field ~= "database" then
addError(
errors,
"unexpected_character_list_field",
path .. "." .. tostring(field),
"캐릭터 목록에는 id와 database만 사용할 수 있습니다."
)
end
end
if not isCharacterDatabaseName(entry.database) then
addError(
errors,
"invalid_character_database_name",
path .. ".database",
"개별 캐릭터 DB 이름은 영문자로 시작하고 영문자, 숫자, 밑줄만 사용한 .db 파일명이어야 합니다."
)
elseif RESERVED_CHARACTER_DATABASES[entry.database] == true then
addError(
errors,
"reserved_character_database_name",
path .. ".database",
"공용 정적 DB 이름은 개별 캐릭터 DB로 사용할 수 없습니다."
)
else
local firstId = firstCharacterByDatabase[entry.database]
if firstId ~= nil then
addError(
errors,
"duplicate_character_database",
path .. ".database",
"개별 캐릭터 DB '" .. entry.database .. "'는 이미 characterList."
.. firstId .. ".database에서 사용되었습니다."
)
else
firstCharacterByDatabase[entry.database] = key
end
end
end
end
end
local function loadCharacterDefinitions(characterList, errors, captured)
local characters = {}
for _, characterId in ipairs(sortedAsciiKeys(characterList)) do
local entry = characterList[characterId]
local database = type(entry) == "table" and entry.database or nil
if isCharacterDatabaseName(database)
and RESERVED_CHARACTER_DATABASES[database] ~= true then
local modules = loadLoreModules(database, errors, captured)
if #modules > 1 then
addError(
errors,
"duplicate_character_module",
database,
"개별 캐릭터 DB는 하나의 로어 엔트리만 등록할 수 있습니다."
)
end
for index, module in ipairs(modules) do
local modulePath = database .. "[" .. index .. "]"
validateModuleHeader(module, "characterDatabase", modulePath, errors)
local collection = module.characters
if type(collection) ~= "table" then
addError(errors, "missing_collection", modulePath .. ".characters", "캐릭터 DB 컬렉션이 없습니다.")
else
local definition = collection[characterId]
if definition == nil then
addError(
errors,
"missing_listed_character",
modulePath .. ".characters." .. characterId,
"캐릭터 목록에 등록된 ID의 정의가 개별 DB에 없습니다."
)
elseif characters[characterId] == nil then
characters[characterId] = definition
end
for definedId in pairs(collection) do
if definedId ~= characterId then
addError(
errors,
"unlisted_character_definition",
modulePath .. ".characters." .. tostring(definedId),
"개별 캐릭터 DB에는 목록에서 연결한 캐릭터 한 명만 정의할 수 있습니다."
)
end
end
end
end
end
end
return characters
end
local function validateRegistryCollection(collection, path, errors, ownerRequired)
if type(collection) ~= "table" then
addError(errors, "missing_registry", path, "레지스트리 컬렉션이 없습니다.")
return
end
for key, entry in pairs(collection) do
local entryPath = path .. "." .. tostring(key)
if not isAsciiId(key) then
addError(errors, "invalid_id", entryPath, "레지스트리 키는 ASCII ID여야 합니다.")
end
if type(entry) ~= "table" or entry.id ~= key then
addError(errors, "id_mismatch", entryPath, "레지스트리 키와 내부 ID가 다릅니다.")
elseif ownerRequired and entry.owner ~= "player" and entry.owner ~= "character" then
addError(errors, "invalid_owner", entryPath .. ".owner", "역할 소유자가 올바르지 않습니다.")
end
end
end
local function validateRegistry(registry, errors)
if type(registry) ~= "table" then
addError(errors, "missing_registry", "registry", "중앙 게임 레지스트리가 없습니다.")
return
end
validateRegistryCollection(registry.cardTypes, "registry.cardTypes", errors, false)
validateRegistryCollection(registry.roles, "registry.roles", errors, true)
validateRegistryCollection(registry.mechanisms, "registry.mechanisms", errors, false)
validateRegistryCollection(registry.ruleTerms, "registry.ruleTerms", errors, false)
validateRegistryCollection(registry.moods, "registry.moods", errors, false)
validateRegistryCollection(registry.events, "registry.events", errors, false)
validateRegistryCollection(registry.effectOps, "registry.effectOps", errors, false)
local registryIds = {}
for _, collectionName in ipairs({ "cardTypes", "roles", "mechanisms", "ruleTerms" }) do
local collection = type(registry[collectionName]) == "table" and registry[collectionName] or {}
for id in pairs(collection) do
if registryIds[id] then
addError(errors, "registry_id_collision", "registry." .. id, "태그와 규칙 용어 ID가 중복됩니다.")
else
registryIds[id] = collectionName
end
end
end
end
local function hasTagToken(description, id)
return type(description) == "string"
and type(id) == "string"
and string.find(description, "::tag[" .. id .. "]::", 1, true) ~= nil
end
local function validateTagTokens(text, path, registry, errors)
if type(text) ~= "string" then
return
end
local remainder = string.gsub(text, "::tag%[[a-z][a-z0-9_]*%]::", "")
if string.find(remainder, "::tag[", 1, true) then
addError(errors, "malformed_tag_token", path, "형식이 잘못된 태그 토큰이 있습니다.")
end
for tagId in string.gmatch(text, "::tag%[([a-z][a-z0-9_]*)%]::") do
local knownCardType = type(registry) == "table"
and type(registry.cardTypes) == "table"
and registry.cardTypes[tagId]
local knownRole = type(registry) == "table"
and type(registry.roles) == "table"
and registry.roles[tagId]
local knownMechanism = type(registry) == "table"
and type(registry.mechanisms) == "table"
and registry.mechanisms[tagId]
local knownRuleTerm = type(registry) == "table"
and type(registry.ruleTerms) == "table"
and registry.ruleTerms[tagId]
if not knownCardType and not knownRole and not knownMechanism and not knownRuleTerm then
addError(errors, "unknown_tag_token", path, "등록되지 않은 태그 토큰입니다: " .. tagId)
end
end
end
local function validateNarration(card, path, isPlan, errors)
if type(card.narration) ~= "table" then
addError(errors, "missing_narration", path .. ".narration", "카드 묘사가 없습니다.")
return
end
if isPlan then
if type(card.narration.planPlaced) ~= "table"
or type(card.narration.planPlaced.actorAction) ~= "string"
or card.narration.planPlaced.actorAction == "" then
addError(errors, "missing_plan_placed_narration", path .. ".narration.planPlaced", "계획 배치 묘사가 없습니다.")
end
if type(card.narration.planTriggered) ~= "table"
or type(card.narration.planTriggered.actorAction) ~= "string"
or card.narration.planTriggered.actorAction == "" then
addError(errors, "missing_plan_triggered_narration", path .. ".narration.planTriggered", "계획 발동 묘사가 없습니다.")
end
elseif type(card.narration.play) ~= "table"
or type(card.narration.play.actorAction) ~= "string"
or card.narration.play.actorAction == "" then
addError(errors, "missing_play_narration", path .. ".narration.play", "카드 사용 묘사가 없습니다.")
end
end
local function validatePlanSelectionAssumption(card, plan, path, registry, errors)
local assumption = plan.selectionAssumption
if card.owner == "character" and assumption == nil then
addError(
errors,
"missing_plan_selection_assumption",
path .. ".selectionAssumption",
"캐릭터 계획에는 선택 점수용 발동 가정이 필요합니다."
)
return
end
if assumption == nil then
return
end
if card.owner ~= "character" then
addError(
errors,
"unexpected_plan_selection_assumption",
path .. ".selectionAssumption",
"선택 점수용 계획 발동 가정은 캐릭터 카드에만 사용할 수 있습니다."
)
end
if type(assumption) ~= "table" then
addError(
errors,
"invalid_plan_selection_assumption",
path .. ".selectionAssumption",
"계획 선택 가정이 테이블이 아닙니다."
)
return
end
for field in pairs(assumption) do
if field ~= "event" and field ~= "chargePolicy" then
addError(
errors,
"unexpected_plan_selection_assumption_field",
path .. ".selectionAssumption." .. tostring(field),
"계획 선택 가정에 허용되지 않은 필드가 있습니다."
)
end
end
local event = assumption.event
local eventPath = path .. ".selectionAssumption.event"
if type(event) ~= "table" then
addError(errors, "invalid_plan_selection_event", eventPath, "계획 선택 가정 사건이 테이블이 아닙니다.")
else
for field in pairs(event) do
if field ~= "type" and field ~= "side" and field ~= "roles" then
addError(
errors,
"unexpected_plan_selection_event_field",
eventPath .. "." .. tostring(field),
"계획 선택 가정 사건에는 type, side와 roles만 사용할 수 있습니다."
)
end
end
if type(registry) ~= "table"
or type(registry.events) ~= "table"
or type(event.type) ~= "string"
or not registry.events[event.type] then
addError(errors, "unknown_plan_selection_event", eventPath .. ".type", "등록되지 않은 계획 선택 가정 사건입니다.")
end
if event.side ~= "player" and event.side ~= "character" then
addError(errors, "invalid_plan_selection_side", eventPath .. ".side", "계획 선택 가정 진영이 올바르지 않습니다.")
end
if not isArray(event.roles) then
addError(errors, "invalid_plan_selection_roles", eventPath .. ".roles", "계획 선택 가정 역할은 배열이어야 합니다.")
else
local seenRoles = {}
for index, roleId in ipairs(event.roles) do
local role = type(registry) == "table"
and type(registry.roles) == "table"
and registry.roles[roleId]
or nil
if type(role) ~= "table" or role.owner ~= event.side or seenRoles[roleId] then
addError(errors, "invalid_plan_selection_role", eventPath .. ".roles[" .. index .. "]", "계획 선택 가정 역할이 진영과 맞지 않거나 중복되었습니다.")
end
seenRoles[roleId] = true
end
end
end
if assumption.chargePolicy ~= "all" then
addError(
errors,
"invalid_plan_selection_charge_policy",
path .. ".selectionAssumption.chargePolicy",
"캐릭터 계획 선택 점수는 모든 충전을 합산해야 합니다."
)
end
if not isPositiveInteger(plan.charges) then
addError(
errors,
"plan_selection_requires_charges",
path .. ".charges",
"캐릭터 계획 선택 점수에는 양의 충전 수가 필요합니다."
)
end
end
local function validateCards(cards, registry, errors)
if type(cards) ~= "table" then
addError(errors, "missing_cards", "cards", "카드 컬렉션이 없습니다.")
return
end
for key, card in pairs(cards) do
local path = "cards." .. tostring(key)
if not isAsciiId(key) then
addError(errors, "invalid_id", path, "카드 키는 ASCII ID여야 합니다.")
end
if type(card) ~= "table" then
addError(errors, "invalid_card", path, "카드 정의가 테이블이 아닙니다.")
else
if card.id ~= key then
addError(errors, "id_mismatch", path .. ".id", "카드 키와 내부 ID가 다릅니다.")
end
if card.owner ~= "player" and card.owner ~= "character" then
addError(errors, "invalid_owner", path .. ".owner", "카드 소유자가 올바르지 않습니다.")
end
if type(card.name) ~= "string" or card.name == "" then
addError(errors, "missing_name", path .. ".name", "카드 이름이 없습니다.")
end
if card.owner == "player" and card.rarity ~= "common" and card.rarity ~= "rare" then
addError(errors, "invalid_rarity", path .. ".rarity", "플레이어 카드 희귀도는 common 또는 rare여야 합니다.")
elseif card.owner == "character" and card.rarity ~= nil then
addError(errors, "unexpected_rarity", path .. ".rarity", "캐릭터 카드에는 희귀도를 사용하지 않습니다.")
end
if type(card.description) ~= "string" or card.description == "" then
addError(errors, "missing_description", path .. ".description", "카드 설명이 없습니다.")
end
validateTagTokens(card.description, path .. ".description", registry, errors)
local cardType = type(registry) == "table"
and type(registry.cardTypes) == "table"
and registry.cardTypes[card.cardType]
or nil
if not cardType then
addError(errors, "unknown_card_type", path .. ".cardType", "등록되지 않은 카드 유형입니다.")
end
if not isArray(card.roles) then
addError(errors, "invalid_roles", path .. ".roles", "카드 역할이 배열이 아닙니다.")
else
local maximum = card.owner == "player" and 2 or 1
if #card.roles < 1 or #card.roles > maximum then
addError(errors, "invalid_role_count", path .. ".roles", "카드 역할 수가 소유자 규칙과 맞지 않습니다.")
end
local seenRoles = {}
for index, roleId in ipairs(card.roles) do
local rolePath = path .. ".roles[" .. index .. "]"
local role = type(registry) == "table"
and type(registry.roles) == "table"
and registry.roles[roleId]
or nil
if not role then
addError(errors, "unknown_role", rolePath, "등록되지 않은 카드 역할입니다.")
elseif role.owner ~= card.owner then
addError(errors, "role_owner_mismatch", rolePath, "카드 역할 소유자와 카드 소유자가 다릅니다.")
end
if seenRoles[roleId] then
addError(errors, "duplicate_role", rolePath, "같은 카드 역할이 중복되었습니다.")
end
seenRoles[roleId] = true
end
end
if not isArray(card.mechanisms) then
addError(errors, "invalid_mechanisms", path .. ".mechanisms", "메커니즘 목록이 배열이 아닙니다.")
else
local seenMechanisms = {}
for index, mechanismId in ipairs(card.mechanisms) do
local mechanismPath = path .. ".mechanisms[" .. index .. "]"
if type(registry) ~= "table"
or type(registry.mechanisms) ~= "table"
or not registry.mechanisms[mechanismId] then
addError(errors, "unknown_mechanism", mechanismPath, "등록되지 않은 메커니즘입니다.")
end
if seenMechanisms[mechanismId] then
addError(errors, "duplicate_mechanism", mechanismPath, "같은 메커니즘이 중복되었습니다.")
end
seenMechanisms[mechanismId] = true
if not hasTagToken(card.description, mechanismId) then
addError(errors, "missing_mechanism_token", path .. ".description", "설명에 메커니즘 태그 토큰이 없습니다.")
end
end
if type(card.mechanismData) == "table" then
for mechanismId in pairs(card.mechanismData) do
if mechanismId ~= "plan" or card.cardType ~= "plan" then
addError(errors, "orphan_mechanism_data", path .. ".mechanismData." .. tostring(mechanismId), "보유하지 않은 메커니즘의 설정입니다.")
end
end
end
local hasPlan = card.cardType == "plan"
local plan = type(card.mechanismData) == "table" and card.mechanismData.plan or nil
if hasPlan then
if type(plan) ~= "table" then
addError(errors, "missing_plan_data", path .. ".mechanismData.plan", "계획 설정이 없습니다.")
else
if plan.durationTurns ~= nil and not isPositiveInteger(plan.durationTurns) then
addError(errors, "invalid_plan_duration", path .. ".mechanismData.plan.durationTurns", "계획 지속 턴은 1 이상의 정수여야 합니다.")
end
if plan.durationIncludesPlacementTurn ~= nil
and type(plan.durationIncludesPlacementTurn) ~= "boolean" then
addError(
errors,
"invalid_plan_duration_policy",
path .. ".mechanismData.plan.durationIncludesPlacementTurn",
"배치 턴 포함 여부는 불리언이어야 합니다."
)
elseif plan.durationIncludesPlacementTurn == true
and not isPositiveInteger(plan.durationTurns) then
addError(
errors,
"plan_duration_policy_requires_duration",
path .. ".mechanismData.plan.durationIncludesPlacementTurn",
"배치 턴을 포함하려면 양의 durationTurns가 필요합니다."
)
end
if plan.charges ~= nil and not isPositiveInteger(plan.charges) then
addError(errors, "invalid_plan_charges", path .. ".mechanismData.plan.charges", "계획 충전은 1 이상의 정수여야 합니다.")
end
local hasLifetime = isPositiveInteger(plan.durationTurns)
or isPositiveInteger(plan.charges)
or type(plan.expires) == "function"
if not hasLifetime then
addError(errors, "unlimited_plan", path .. ".mechanismData.plan", "계획 수명이 제한되지 않았습니다.")
end
if type(plan.trigger) ~= "function" then
addError(errors, "invalid_plan_trigger", path .. ".mechanismData.plan.trigger", "계획 조건이 함수가 아닙니다.")
end
if type(plan.resolve) ~= "function" then
addError(errors, "invalid_plan_resolve", path .. ".mechanismData.plan.resolve", "계획 효과가 함수가 아닙니다.")
end
validatePlanSelectionAssumption(
card,
plan,
path .. ".mechanismData.plan",
registry,
errors
)
end
elseif plan ~= nil then
addError(errors, "unexpected_plan_data", path .. ".mechanismData.plan", "계획 메커니즘이 없는 카드에 계획 설정이 있습니다.")
end
local selectionPreview = card.selectionPreview
if selectionPreview ~= nil then
local previewPath = path .. ".selectionPreview"
if card.owner ~= "player" or card.cardType ~= "chain" then
addError(errors, "preview_requires_player_chain", previewPath, "선택 단계 효과는 플레이어 연계 카드에만 선언할 수 있습니다.")
end
if card.resolve ~= nil then
addError(errors, "preview_with_resolve", path .. ".resolve", "선택 단계 효과 카드에는 v1 resolve를 함께 둘 수 없습니다.")
end
if type(selectionPreview) ~= "table" then
addError(errors, "invalid_selection_preview", previewPath, "선택 단계 효과 정책이 테이블이 아닙니다.")
else
for field in pairs(selectionPreview) do
if field ~= "effects" then
addError(errors, "unexpected_preview_field", previewPath .. "." .. tostring(field), "선택 단계 효과 정책에 허용되지 않은 필드가 있습니다.")
end
end
if not isArray(selectionPreview.effects) or #selectionPreview.effects == 0 then
addError(errors, "invalid_preview_effects", previewPath .. ".effects", "선택 단계 효과는 비어 있지 않은 배열이어야 합니다.")
else
local seenEffectIds = {}
for effectIndex, effect in ipairs(selectionPreview.effects) do
local effectPath = previewPath .. ".effects[" .. effectIndex .. "]"
if type(effect) ~= "table" then
addError(errors, "invalid_preview_effect", effectPath, "선택 단계 효과가 테이블이 아닙니다.")
else
for field in pairs(effect) do
if field ~= "id" and field ~= "op" and field ~= "target" and field ~= "amount" then
addError(errors, "unexpected_preview_effect_field", effectPath .. "." .. tostring(field), "선택 단계 효과에 허용되지 않은 필드가 있습니다.")
end
end
if not isAsciiId(effect.id) then
addError(errors, "invalid_preview_effect_id", effectPath .. ".id", "선택 단계 효과 ID는 ASCII ID여야 합니다.")
elseif seenEffectIds[effect.id] then
addError(errors, "duplicate_preview_effect_id", effectPath .. ".id", "선택 단계 효과 ID가 중복되었습니다.")
else
seenEffectIds[effect.id] = true
end
local registeredOp = type(registry) == "table"
and type(registry.effectOps) == "table"
and registry.effectOps[effect.op]
or nil
if not registeredOp then
addError(errors, "unknown_preview_op", effectPath .. ".op", "등록되지 않은 선택 단계 효과 작업입니다.")
elseif effect.op ~= "draw_cards" then
addError(errors, "unsupported_preview_op", effectPath .. ".op", "선택 단계 효과 v1은 draw_cards만 지원합니다.")
end
if effect.target ~= "player" then
addError(errors, "invalid_preview_target", effectPath .. ".target", "선택 단계 드로우 대상은 player여야 합니다.")
end
if not isPositiveInteger(effect.amount) then
addError(errors, "invalid_preview_amount", effectPath .. ".amount", "선택 단계 드로우 수량은 1 이상의 정수여야 합니다.")
end
end
end
end
end
end
validateNarration(card, path, hasPlan, errors)
if hasPlan and type(card.effectChoices) == "table" then
local hasImmediateChoice = false
for _, choice in ipairs(card.effectChoices) do
if type(choice) == "table" and choice.placesPlan == false then
hasImmediateChoice = true
end
end
if hasImmediateChoice
and (type(card.narration) ~= "table"
or type(card.narration.play) ~= "table"
or type(card.narration.play.actorAction) ~= "string"
or card.narration.play.actorAction == "") then
addError(errors, "missing_immediate_choice_narration", path .. ".narration.play", "즉시 발동 선택지가 있는 계획 카드에는 사용 묘사가 필요합니다.")
end
end
end
if type(card.base) ~= "table" then
addError(errors, "missing_base", path .. ".base", "카드 기본 수치가 없습니다.")
else
if not isFiniteNonNegative(card.base.stealthCost) then
addError(errors, "invalid_stealth_cost", path .. ".base.stealthCost", "기본 은폐 비용이 0 이상의 유한한 숫자가 아닙니다.")
end
if not isFiniteNonNegative(card.base.resistanceDamage) then
addError(errors, "invalid_resistance_damage", path .. ".base.resistanceDamage", "기본 저항 피해가 0 이상의 유한한 숫자가 아닙니다.")
end
end
if card.stealthCostByMood ~= nil then
if card.owner ~= "player" or type(card.stealthCostByMood) ~= "table" then
addError(errors, "invalid_mood_stealth_costs", path .. ".stealthCostByMood", "플레이어 카드의 무드별 은폐 비용은 테이블이어야 합니다.")
else
for moodId, cost in pairs(card.stealthCostByMood) do
if type(registry) ~= "table"
or type(registry.moods) ~= "table"
or registry.moods[moodId] == nil then
addError(errors, "unknown_mood_stealth_cost", path .. ".stealthCostByMood." .. tostring(moodId), "등록되지 않은 무드의 은폐 비용입니다.")
elseif not isFiniteNonNegative(cost) then
addError(errors, "invalid_mood_stealth_cost", path .. ".stealthCostByMood." .. moodId, "무드별 은폐 비용이 0 이상의 유한한 숫자가 아닙니다.")
end
end
end
end
if not isArray(card.rules) or #card.rules == 0 then
addError(errors, "invalid_rules", path .. ".rules", "카드 규칙 문장이 비어 있습니다.")
else
for index, rule in ipairs(card.rules) do
if type(rule) ~= "string" or rule == "" then
addError(errors, "invalid_rule", path .. ".rules[" .. index .. "]", "카드 규칙이 빈 문자열이거나 문자열이 아닙니다.")
else
validateTagTokens(rule, path .. ".rules[" .. index .. "]", registry, errors)
end
end
end
if card.canPlay ~= nil and type(card.canPlay) ~= "function" then
addError(errors, "invalid_can_play", path .. ".canPlay", "사용 조건이 함수가 아닙니다.")
end
if card.resolve ~= nil and type(card.resolve) ~= "function" then
addError(errors, "invalid_resolve", path .. ".resolve", "카드 효과가 함수가 아닙니다.")
end
if card.effectChoices ~= nil then
local choicesPath = path .. ".effectChoices"
if card.owner ~= "player" then
addError(errors, "choice_requires_player", choicesPath, "효과 선택지는 플레이어 카드에만 선언할 수 있습니다.")
end
if not isArray(card.effectChoices) or #card.effectChoices < 2 then
addError(errors, "invalid_effect_choices", choicesPath, "효과 선택지는 2개 이상의 배열이어야 합니다.")
else
local seenChoiceIds = {}
for choiceIndex, choice in ipairs(card.effectChoices) do
local choicePath = choicesPath .. "[" .. choiceIndex .. "]"
if type(choice) ~= "table" then
addError(errors, "invalid_effect_choice", choicePath, "효과 선택지가 테이블이 아닙니다.")
else
for field in pairs(choice) do
if field ~= "id"
and field ~= "label"
and field ~= "description"
and field ~= "actorAction"
and field ~= "unavailableText"
and field ~= "canSelect"
and field ~= "placesPlan" then
addError(errors, "unexpected_effect_choice_field", choicePath .. "." .. tostring(field), "효과 선택지에 허용되지 않은 필드가 있습니다.")
end
end
if not isAsciiId(choice.id) then
addError(errors, "invalid_effect_choice_id", choicePath .. ".id", "효과 선택지 ID는 ASCII ID여야 합니다.")
elseif seenChoiceIds[choice.id] then
addError(errors, "duplicate_effect_choice_id", choicePath .. ".id", "효과 선택지 ID가 중복되었습니다.")
else
seenChoiceIds[choice.id] = true
end
if type(choice.label) ~= "string" or choice.label == "" then
addError(errors, "invalid_effect_choice_label", choicePath .. ".label", "효과 선택지 표시명이 필요합니다.")
end
if type(choice.description) ~= "string" or choice.description == "" then
addError(errors, "invalid_effect_choice_description", choicePath .. ".description", "효과 선택지 설명이 필요합니다.")
else
validateTagTokens(choice.description, choicePath .. ".description", registry, errors)
end
if choice.actorAction ~= nil
and (type(choice.actorAction) ~= "string" or choice.actorAction == "") then
addError(errors, "invalid_effect_choice_actor_action", choicePath .. ".actorAction", "선택지 행동 묘사는 비어 있지 않은 문자열이어야 합니다.")
end
if choice.unavailableText ~= nil
and (type(choice.unavailableText) ~= "string" or choice.unavailableText == "") then
addError(errors, "invalid_effect_choice_unavailable_text", choicePath .. ".unavailableText", "비활성 안내는 비어 있지 않은 문자열이어야 합니다.")
end
if choice.canSelect ~= nil and type(choice.canSelect) ~= "function" then
addError(errors, "invalid_effect_choice_predicate", choicePath .. ".canSelect", "효과 선택 가능 조건이 함수가 아닙니다.")
end
if choice.placesPlan ~= nil then
if type(choice.placesPlan) ~= "boolean" then
addError(errors, "invalid_effect_choice_plan_flag", choicePath .. ".placesPlan", "계획 배치 여부는 불리언이어야 합니다.")
elseif card.cardType ~= "plan" then
addError(errors, "effect_choice_plan_without_type", choicePath .. ".placesPlan", "계획 카드가 아닌 카드에는 계획 배치 선택을 선언할 수 없습니다.")
end
end
end
end
end
end
if card.moodEffects ~= nil then
if type(card.moodEffects) ~= "table" then
addError(errors, "invalid_mood_effects", path .. ".moodEffects", "무드 효과가 테이블이 아닙니다.")
else
for moodId, callback in pairs(card.moodEffects) do
if type(registry) ~= "table"
or type(registry.moods) ~= "table"
or not registry.moods[moodId] then
addError(errors, "unknown_mood", path .. ".moodEffects." .. tostring(moodId), "등록되지 않은 무드입니다.")
end
if type(callback) ~= "function" then
addError(errors, "invalid_mood_callback", path .. ".moodEffects." .. tostring(moodId), "무드 효과가 함수가 아닙니다.")
end
end
end
end
end
end
end
local function validateTraits(traits, errors)
if type(traits) ~= "table" then
addError(errors, "missing_traits", "traits", "특징 컬렉션이 없습니다.")
return
end
for key, trait in pairs(traits) do
local path = "traits." .. tostring(key)
if not isAsciiId(key) or type(trait) ~= "table" or trait.id ~= key then
addError(errors, "invalid_trait_id", path, "특징 키와 내부 ID가 올바르지 않습니다.")
else
if trait.owner ~= "player" and trait.owner ~= "character" then
addError(errors, "invalid_owner", path .. ".owner", "특징 소유자가 올바르지 않습니다.")
end
if trait.visibility ~= "public" and trait.visibility ~= "hidden" then
addError(errors, "invalid_visibility", path .. ".visibility", "특징 공개 범위가 올바르지 않습니다.")
end
if type(trait.name) ~= "string" or trait.name == "" then
addError(errors, "missing_name", path .. ".name", "특징 이름이 없습니다.")
end
if type(trait.description) ~= "string" or trait.description == "" then
addError(errors, "missing_description", path .. ".description", "특징 설명이 없습니다.")
end
if not isArray(trait.rules) or #trait.rules == 0 then
addError(errors, "invalid_rules", path .. ".rules", "특징 규칙 문장이 비어 있습니다.")
end
if trait.modifiers ~= nil then
local modifiersPath = path .. ".modifiers"
if not isArray(trait.modifiers) then
addError(errors, "invalid_trait_modifiers", modifiersPath, "특징 보정은 연속 배열이어야 합니다.")
else
for index, modifier in ipairs(trait.modifiers) do
local modifierPath = modifiersPath .. "[" .. index .. "]"
if type(modifier) ~= "table" then
addError(
errors,
"invalid_trait_modifier",
modifierPath,
"특징 보정 항목은 객체여야 합니다."
)
else
local allowed = {
stat = true,
operation = true,
amount = true,
}
for field in pairs(modifier) do
if not allowed[field] then
addError(
errors,
"unknown_trait_modifier_field",
modifierPath .. "." .. tostring(field),
"특징 보정에 허용되지 않은 필드가 있습니다."
)
end
end
if modifier.stat ~= "planCapacity" then
addError(
errors,
"unsupported_trait_modifier_stat",
modifierPath .. ".stat",
"현재 특징 보정은 planCapacity만 지원합니다."
)
end
if modifier.operation ~= "add" then
addError(
errors,
"unsupported_trait_modifier_operation",
modifierPath .. ".operation",
"planCapacity 특징 보정은 add 연산만 지원합니다."
)
end
if not isFiniteInteger(modifier.amount) then
addError(
errors,
"invalid_trait_modifier_amount",
modifierPath .. ".amount",
"planCapacity 특징 보정량은 유한한 정수여야 합니다."
)
end
end
end
end
end
end
end
end
local function validateSubwayLines(subwayLines, errors)
if type(subwayLines) ~= "table" then
addError(errors, "missing_subway_lines", "subwayLines", "지하철 노선 컬렉션이 없습니다.")
return
end
if next(subwayLines) == nil then
addError(errors, "empty_subway_lines", "subwayLines", "지하철 노선 컬렉션이 비어 있습니다.")
return
end
for key, line in pairs(subwayLines) do
local linePath = "subwayLines." .. tostring(key)
if not isAsciiId(key) or type(line) ~= "table" or line.id ~= key then
addError(errors, "invalid_subway_line_id", linePath, "노선 키와 내부 ID가 올바르지 않습니다.")
else
if not isAsciiId(line.operatorId) then
addError(errors, "invalid_subway_operator_id", linePath .. ".operatorId", "운영사 ID가 올바르지 않습니다.")
end
if type(line.operatorName) ~= "string" or line.operatorName == "" then
addError(errors, "missing_subway_operator_name", linePath .. ".operatorName", "운영사 표시명이 없습니다.")
end
if type(line.name) ~= "string" or line.name == "" then
addError(errors, "missing_subway_line_name", linePath .. ".name", "노선 표시명이 없습니다.")
end
if type(line.code) ~= "string"
or string.match(line.code, "^[A-Z]$") == nil then
addError(errors, "invalid_subway_line_code", linePath .. ".code", "노선 코드는 영문 대문자 한 글자여야 합니다.")
end
if type(line.color) ~= "string"
or string.match(line.color, "^#%x%x%x%x%x%x$") == nil then
addError(errors, "invalid_subway_line_color", linePath .. ".color", "노선 색상은 #RRGGBB 형식이어야 합니다.")
end
if not isArray(line.paths) or #line.paths == 0 then
addError(errors, "invalid_subway_paths", linePath .. ".paths", "노선 경로 목록이 비어 있습니다.")
else
local pathIds = {}
local stationDefinitions = {}
local supportsMinimum = false
local supportsMaximum = false
for pathIndex, routePath in ipairs(line.paths) do
local pathPath = linePath .. ".paths[" .. pathIndex .. "]"
if type(routePath) ~= "table" then
addError(errors, "invalid_subway_path", pathPath, "노선 경로가 테이블이 아닙니다.")
else
if not isAsciiId(routePath.id) then
addError(errors, "invalid_subway_path_id", pathPath .. ".id", "노선 경로 ID가 올바르지 않습니다.")
elseif pathIds[routePath.id] then
addError(errors, "duplicate_subway_path_id", pathPath .. ".id", "노선 경로 ID가 중복되었습니다.")
else
pathIds[routePath.id] = true
end
if type(routePath.circular) ~= "boolean" then
addError(errors, "invalid_subway_path_circular", pathPath .. ".circular", "순환 경로 여부는 불리언이어야 합니다.")
end
if not isArray(routePath.stations) or #routePath.stations < 2 then
addError(errors, "invalid_subway_stations", pathPath .. ".stations", "경로에는 두 개 이상의 역이 필요합니다.")
else
if routePath.circular == true or #routePath.stations >= 8 then
supportsMinimum = true
end
if routePath.circular == true or #routePath.stations >= 13 then
supportsMaximum = true
end
local stationIds = {}
for stationIndex, station in ipairs(routePath.stations) do
local stationPath = pathPath .. ".stations[" .. stationIndex .. "]"
if type(station) ~= "table" then
addError(errors, "invalid_subway_station", stationPath, "역 정의가 테이블이 아닙니다.")
else
if not isAsciiId(station.id) then
addError(errors, "invalid_subway_station_id", stationPath .. ".id", "역 ID가 올바르지 않습니다.")
elseif stationIds[station.id] then
addError(errors, "duplicate_subway_station_id", stationPath .. ".id", "한 경로 안에서 역 ID가 중복되었습니다.")
else
stationIds[station.id] = true
end
if type(station.code) ~= "string"
or string.match(station.code, "^[A-Za-z]+[0-9][0-9]$") == nil then
addError(errors, "invalid_subway_station_code", stationPath .. ".code", "역 코드는 영문자와 두 자리 숫자로 구성해야 합니다.")
end
if type(station.name) ~= "string" or station.name == "" then
addError(errors, "missing_subway_station_name", stationPath .. ".name", "역 표시명이 없습니다.")
end
local previous = stationDefinitions[station.id]
if previous ~= nil
and (previous.code ~= station.code or previous.name ~= station.name) then
addError(errors, "subway_station_definition_conflict", stationPath, "같은 역 ID의 코드나 표시명이 경로마다 다릅니다.")
elseif previous == nil and isAsciiId(station.id) then
stationDefinitions[station.id] = {
code = station.code,
name = station.name,
}
end
end
end
end
end
end
if not supportsMinimum then
addError(errors, "subway_line_too_short", linePath .. ".paths", "이 노선에는 7턴 여정을 만들 수 있는 경로가 없습니다.")
end
if not supportsMaximum then
addError(errors, "subway_line_missing_maximum_route", linePath .. ".paths", "이 노선에는 12턴 여정을 만들 수 있는 경로가 없습니다.")
end
end
end
end
end
local function validateCharacters(characters, cards, traits, registry, errors)
if type(characters) ~= "table" then
addError(errors, "missing_characters", "characters", "캐릭터 컬렉션이 없습니다.")
return
end
local namedCharacters = {}
for key, character in pairs(characters) do
local path = "characters." .. tostring(key)
if not isAsciiId(key) or type(character) ~= "table" or character.id ~= key then
addError(errors, "invalid_character_id", path, "캐릭터 키와 내부 ID가 올바르지 않습니다.")
else
if type(character.name) ~= "string" or character.name == "" then
addError(errors, "missing_name", path .. ".name", "캐릭터 이름이 없습니다.")
else
table.insert(namedCharacters, {
id = key,
name = character.name,
})
end
if type(character.publicProfile) ~= "table" then
addError(errors, "missing_public_profile", path .. ".publicProfile", "공개 프로필이 없습니다.")
end
if character.privateProfile ~= nil and type(character.privateProfile) ~= "table" then
addError(errors, "invalid_private_profile", path .. ".privateProfile", "비공개 프로필이 테이블이 아닙니다.")
end
local battle = character.battle
if type(battle) ~= "table" then
addError(errors, "missing_battle", path .. ".battle", "전투 정의가 없습니다.")
else
if not isFiniteNonNegative(battle.startingResistance) or battle.startingResistance == 0 then
addError(errors, "invalid_starting_resistance", path .. ".battle.startingResistance", "시작 저항이 양수의 유한한 숫자가 아닙니다.")
end
if not isPositiveInteger(battle.turnLimit) or battle.turnLimit < 7 or battle.turnLimit > 12 then
addError(errors, "invalid_turn_limit", path .. ".battle.turnLimit", "캐릭터 제한 턴은 7 이상 12 이하의 정수여야 합니다.")
end
if not isPositiveInteger(battle.baseDrawCount) then
addError(errors, "invalid_base_draw_count", path .. ".battle.baseDrawCount", "기본 드로우 수는 1 이상의 정수여야 합니다.")
end
if not isPositiveInteger(battle.maxHandSize) then
addError(errors, "invalid_hand_size", path .. ".battle.maxHandSize", "최대 손패는 1 이상의 정수여야 합니다.")
end
if not isPlanCapacity(battle.planCapacity) then
addError(errors, "invalid_plan_capacity", path .. ".battle.planCapacity", "기본 계획 용량은 1 이상 16 이하의 정수여야 합니다.")
end
if isPositiveInteger(battle.baseDrawCount)
and isPositiveInteger(battle.maxHandSize)
and battle.baseDrawCount > battle.maxHandSize then
addError(errors, "draw_exceeds_hand_limit", path .. ".battle.baseDrawCount", "기본 드로우 수는 최대 손패보다 클 수 없습니다.")
end
if type(registry) ~= "table"
or type(registry.moods) ~= "table"
or not registry.moods[battle.startingMood] then
addError(errors, "unknown_starting_mood", path .. ".battle.startingMood", "등록되지 않은 시작 무드입니다.")
end
if not isArray(battle.traitIds) then
addError(errors, "invalid_trait_ids", path .. ".battle.traitIds", "특징 ID 목록이 배열이 아닙니다.")
else
local effectivePlanCapacity = battle.planCapacity
for index, traitId in ipairs(battle.traitIds) do
local trait = traits[traitId]
if not trait or trait.owner ~= "character" then
addError(errors, "unknown_character_trait", path .. ".battle.traitIds[" .. index .. "]", "캐릭터 특징을 찾을 수 없습니다.")
elseif isFiniteInteger(effectivePlanCapacity) and isArray(trait.modifiers) then
for _, modifier in ipairs(trait.modifiers) do
if type(modifier) == "table"
and modifier.stat == "planCapacity"
and modifier.operation == "add"
and isFiniteInteger(modifier.amount) then
effectivePlanCapacity = effectivePlanCapacity + modifier.amount
end
end
end
end
if isPlanCapacity(battle.planCapacity)
and not isPlanCapacity(effectivePlanCapacity) then
addError(
errors,
"invalid_effective_plan_capacity",
path .. ".battle.planCapacity",
"기본 계획 용량과 특징 보정의 합은 1 이상 16 이하여야 합니다."
)
end
end
if not isArray(battle.deck) or #battle.deck == 0 then
addError(errors, "invalid_character_deck", path .. ".battle.deck", "캐릭터 덱이 비어 있거나 배열이 아닙니다.")
else
for index, cardId in ipairs(battle.deck) do
local card = cards[cardId]
if not card or card.owner ~= "character" then
addError(errors, "invalid_character_card", path .. ".battle.deck[" .. index .. "]", "캐릭터 카드를 찾을 수 없습니다.")
end
end
end
end
end
end
-- pairs 순서에 따라 중복 오류의 위치가 달라지지 않도록 내부 ID 순으로
-- 검사한다. 같은 표시 이름은 선택 화면에서 구분할 수 없으므로 정적 데이터
-- 로딩 자체를 실패시킨다.
table.sort(namedCharacters, function(left, right)
return left.id < right.id
end)
local firstCharacterByName = {}
for _, entry in ipairs(namedCharacters) do
local firstId = firstCharacterByName[entry.name]
if firstId ~= nil then
addError(
errors,
"duplicate_character_name",
"characters." .. entry.id .. ".name",
"캐릭터 이름 '" .. entry.name .. "'은 이미 characters."
.. firstId .. ".name에서 사용되었습니다. 캐릭터 이름은 중복될 수 없습니다."
)
else
firstCharacterByName[entry.name] = entry.id
end
end
end
local function validateCapturedStaticData(captured, discoveredCharacterList, discoveryErrors)
local errors = {}
for _, item in ipairs(discoveryErrors or {}) do
table.insert(errors, item)
end
local registry = loadSingleModule(SOURCES.registry, errors, captured)
local cards = loadMergedCollection(SOURCES.cards, errors, captured)
local traits = loadMergedCollection(SOURCES.traits, errors, captured)
local subwayLines = loadMergedCollection(SOURCES.subwayLines, errors, captured)
local characterList = discoveredCharacterList
if characterList == nil then
characterList = loadMergedCollection(SOURCES.characterList, errors, captured)
end
validateCharacterList(characterList, errors)
local characters = loadCharacterDefinitions(characterList, errors, captured)
validateRegistry(registry, errors)
validateCards(cards, registry, errors)
validateTraits(traits, errors)
validateSubwayLines(subwayLines, errors)
validateCharacters(characters, cards, traits, registry, errors)
return {
ok = #errors == 0,
schemaVersion = SUPPORTED_SCHEMA_VERSION,
errors = errors,
counts = {
cards = countEntries(cards),
traits = countEntries(traits),
subwayLines = countEntries(subwayLines),
characters = countEntries(characters),
},
data = #errors == 0 and {
registry = registry,
cards = cards,
traits = traits,
subwayLines = subwayLines,
characters = characters,
} or nil,
}
end
local function loadAndValidateAll(forceRefresh)
staticCacheStats.requests = staticCacheStats.requests + 1
emitDiagnostic(2, "validation_requested", {
forceRefresh = forceRefresh == true,
cachedEntries = #staticCacheEntries,
})
-- main production warm cache는 bundle revision + mode + chat + character별로
-- handler closure를 분리한다. 따라서 같은 handler의 성공 snapshot은 source를
-- 다시 읽지 않고 안전하게 재사용할 수 있다. main 없이 직접 실행되는 로컬
-- 계약 검사는 global이 nil이므로 기존처럼 매번 source identity를 확인한다.
if forceRefresh ~= true
and RUNTIME_CACHE_DEVELOPMENT_BYPASS == false
and #staticCacheEntries > 0 then
local newest = staticCacheEntries[1]
for index = 2, #staticCacheEntries do
if staticCacheEntries[index].lastUsed > newest.lastUsed then
newest = staticCacheEntries[index]
end
end
staticCacheClock = staticCacheClock + 1
newest.lastUsed = staticCacheClock
newest.hits = newest.hits + 1
staticCacheStats.fastHits = staticCacheStats.fastHits + 1
staticCacheStats.hits = staticCacheStats.hits + 1
emitDiagnostic(2, "validation_cache_hit", {
cache = "production_fast",
ok = newest.report.ok == true,
})
return cloneStaticValue(newest.report)
end
local captured = captureBaseStaticLores(triggerId)
local discoveredCharacterList = nil
local discoveryErrors = nil
local dynamicLoreOrder = findCachedDynamicLoreOrder(captured)
if dynamicLoreOrder == nil then
discoveryErrors = {}
discoveredCharacterList = loadMergedCollection(SOURCES.characterList, discoveryErrors, captured)
dynamicLoreOrder = collectCharacterDatabaseNames(discoveredCharacterList)
end
for _, database in ipairs(dynamicLoreOrder) do
captureStaticLore(triggerId, captured, database)
end
local cached = findStaticCacheEntry(captured)
if cached ~= nil then
staticCacheStats.hits = staticCacheStats.hits + 1
emitDiagnostic(2, "validation_cache_hit", {
cache = "source_identity",
ok = cached.report.ok == true,
})
return cloneStaticValue(cached.report)
end
staticCacheStats.misses = staticCacheStats.misses + 1
staticCacheStats.validations = staticCacheStats.validations + 1
local report = validateCapturedStaticData(captured, discoveredCharacterList, discoveryErrors)
emitDiagnostic(report.ok == true and 2 or 1, "validation_completed", {
ok = report.ok == true,
errorCount = #report.errors,
cards = report.counts.cards,
traits = report.counts.traits,
subwayLines = report.counts.subwayLines,
characters = report.counts.characters,
})
if report.ok == true then
-- canonical report는 cache 내부에만 남기고 호출자에게는 별도 snapshot을 준다.
storeStaticCacheEntry(captured, report)
return cloneStaticValue(report)
end
-- 실패한 compile/validation 결과는 수정 후 즉시 다시 검사할 수 있도록 캐시하지 않는다.
staticCacheStats.failedValidations = staticCacheStats.failedValidations + 1
return report
end
local actions = {
loadAll = loadAndValidateAll,
validateAll = function()
local result = loadAndValidateAll()
result.data = nil
return result
end,
reloadAll = function()
return loadAndValidateAll(true)
end,
cacheStats = function()
return {
ok = true,
schemaVersion = SUPPORTED_SCHEMA_VERSION,
errors = {},
cache = getStaticCacheDiagnostics(),
}
end,
clearCache = function()
return {
ok = true,
schemaVersion = SUPPORTED_SCHEMA_VERSION,
errors = {},
removed = clearStaticCache(),
}
end,
}
emitDiagnostic(2, "request", {
action = tostring(action),
})
local handler = actions[action]
if not handler then
return {
ok = false,
schemaVersion = SUPPORTED_SCHEMA_VERSION,
errors = {
{
code = "unknown_action",
path = "staticData",
message = "지원하지 않는 정적 데이터 작업입니다: " .. tostring(action),
},
},
}
end
return handler()
end
end)()
#49subwayJourney.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local ALGORITHM = "tokyo_subway_segment_v1"
local MIN_TURNS = 7
local MAX_TURNS = 12
local SEED_MODULUS = 2147483646
local ROUTE_DOMAIN_OFFSET = 130363
local SCENE_DOMAIN_OFFSET = 425033
local MAX_SAFE_INTEGER = 9007199254740991
local WEEKDAYS = { "월요일", "화요일", "수요일", "목요일", "금요일", "토요일", "일요일" }
local WEEKDAY_TIMES = { "아침 출근시간", "한낮", "저녁 퇴근시간", "심야열차" }
local WEEKEND_TIMES = { "아침 시간", "한낮", "저녁 시간", "심야열차" }
local WEATHER_POOL = { "맑음", "맑음", "맑음", "흐림", "흐림", "가벼운 비", "가벼운 비", "거센 비" }
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
algorithm = ALGORITHM,
errors = errors,
}
end
local function success(value)
value.ok = true
value.schemaVersion = SCHEMA_VERSION
value.algorithm = ALGORITHM
value.errors = {}
return value
end
local function isSafeInteger(value, minimum)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
and value % 1 == 0
and math.abs(value) <= MAX_SAFE_INTEGER
and (minimum == nil or value >= minimum)
end
local function normalizeStaticData(staticData)
if type(staticData) == "table" and type(staticData.data) == "table" then
return staticData.data
end
return staticData
end
local function deriveRouteSeed(seed)
local normalized = seed % SEED_MODULUS
return ((normalized + ROUTE_DOMAIN_OFFSET) % SEED_MODULUS) + 1
end
local function deriveSceneSeed(seed)
local normalized = seed % SEED_MODULUS
return ((normalized + SCENE_DOMAIN_OFFSET) % SEED_MODULUS) + 1
end
local function sortedKeys(collection)
local keys = {}
for key in pairs(type(collection) == "table" and collection or {}) do
keys[#keys + 1] = key
end
table.sort(keys)
return keys
end
local function copyArray(source)
local copy = {}
for index, value in ipairs(source or {}) do
copy[index] = value
end
return copy
end
local function nextInteger(rng, minimum, maximum, path)
if type(runScript) ~= "function" then
return nil, nil, makeError(
"runtime_unavailable",
"$.runtime.deterministicRng",
"여정 추첨에 필요한 결정적 RNG를 호출할 수 없습니다."
)
end
local ok, report = pcall(
runScript,
triggerId,
"deterministicRng",
"nextInteger",
rng,
minimum,
maximum
)
if not ok then
return nil, nil, makeError(
"rng_call_failed",
"$.runtime.deterministicRng",
"여정 추첨 RNG 호출에 실패했습니다: " .. tostring(report)
)
end
if type(report) ~= "table" or report.ok ~= true
or not isSafeInteger(report.value, minimum)
or report.value > maximum
or type(report.rng) ~= "table"
or report.rng.seed ~= rng.seed
or not isSafeInteger(report.rng.cursor, rng.cursor + 1) then
return nil, nil, makeError(
"invalid_rng_result",
path,
"여정 추첨 RNG가 올바른 값과 다음 상태를 반환하지 않았습니다."
)
end
return report.value, report.rng, nil
end
local function linearCandidates(routePath, turnLimit)
local candidates = {}
local stations = routePath.stations
local lastStart = #stations - turnLimit
if lastStart < 1 then
return candidates
end
for startIndex = 1, lastStart do
local forward = {}
local reverse = {}
for offset = 0, turnLimit do
forward[#forward + 1] = stations[startIndex + offset].id
reverse[#reverse + 1] = stations[startIndex + turnLimit - offset].id
end
candidates[#candidates + 1] = forward
candidates[#candidates + 1] = reverse
end
return candidates
end
local function circularCandidates(routePath, turnLimit)
local candidates = {}
local stations = routePath.stations
local stationCount = #stations
if stationCount < turnLimit + 1 then
return candidates
end
for startIndex = 1, stationCount do
for _, direction in ipairs({ 1, -1 }) do
local stationIds = {}
local cursor = startIndex
for _ = 0, turnLimit do
stationIds[#stationIds + 1] = stations[cursor].id
cursor = ((cursor - 1 + direction) % stationCount) + 1
end
candidates[#candidates + 1] = stationIds
end
end
return candidates
end
local function collectLineCandidates(line, turnLimit)
local candidates = {}
for _, routePath in ipairs(type(line) == "table" and line.paths or {}) do
local pathCandidates
if routePath.circular == true then
pathCandidates = circularCandidates(routePath, turnLimit)
else
pathCandidates = linearCandidates(routePath, turnLimit)
end
for _, stationIds in ipairs(pathCandidates) do
candidates[#candidates + 1] = {
pathId = routePath.id,
stationIds = stationIds,
}
end
end
return candidates
end
local function buildSceneContext(seed)
local rng = { seed = deriveSceneSeed(seed), cursor = 0 }
local weekdayIndex, nextRng, rngError
repeat
weekdayIndex, nextRng, rngError = nextInteger(rng, 1, 8, "$.sceneContext.weekday")
if rngError then return nil, rngError end
rng = nextRng
until weekdayIndex <= #WEEKDAYS
local times = weekdayIndex <= 5 and WEEKDAY_TIMES or WEEKEND_TIMES
local timeIndex
timeIndex, nextRng, rngError = nextInteger(rng, 1, #times, "$.sceneContext.time")
if rngError then return nil, rngError end
rng = nextRng
local weatherIndex
weatherIndex, _, rngError = nextInteger(rng, 1, #WEATHER_POOL, "$.sceneContext.weather")
if rngError then return nil, rngError end
return {
weekday = WEEKDAYS[weekdayIndex],
time = times[timeIndex],
weather = WEATHER_POOL[weatherIndex],
}, nil
end
local function build(seed, staticInput, requestedTurnLimit)
if not isSafeInteger(seed, 0) then
return failure({
makeError("invalid_seed", "$.seed", "여정 생성 seed는 0 이상의 안전 정수여야 합니다."),
})
end
if not isSafeInteger(requestedTurnLimit, MIN_TURNS) or requestedTurnLimit > MAX_TURNS then
return failure({
makeError("invalid_turn_limit", "$.turnLimit", "제한 턴은 7 이상 12 이하의 정수여야 합니다."),
})
end
local staticData = normalizeStaticData(staticInput)
local subwayLines = type(staticData) == "table" and staticData.subwayLines or nil
if type(subwayLines) ~= "table" or next(subwayLines) == nil then
return failure({
makeError("missing_subway_lines", "$.staticData.subwayLines", "여정 생성에 필요한 지하철 노선 DB가 없습니다."),
})
end
local rng = {
seed = deriveRouteSeed(seed),
cursor = 0,
}
local turnLimit, nextRng, rngError = nextInteger(
rng,
requestedTurnLimit,
requestedTurnLimit,
"$.turnLimit"
)
if rngError then
return failure({ rngError })
end
rng = nextRng
local eligible = {}
for _, lineId in ipairs(sortedKeys(subwayLines)) do
local candidates = collectLineCandidates(subwayLines[lineId], turnLimit)
if #candidates > 0 then
eligible[#eligible + 1] = {
lineId = lineId,
candidates = candidates,
}
end
end
if #eligible == 0 then
return failure({
makeError("no_eligible_subway_line", "$.staticData.subwayLines", "추첨된 턴 수에 맞는 지하철 노선이 없습니다."),
})
end
local lineIndex
lineIndex, nextRng, rngError = nextInteger(
rng,
1,
#eligible,
"$.lineIndex"
)
if rngError then
return failure({ rngError })
end
rng = nextRng
local selectedLine = eligible[lineIndex]
local candidateIndex
candidateIndex, nextRng, rngError = nextInteger(
rng,
1,
#selectedLine.candidates,
"$.candidateIndex"
)
if rngError then
return failure({ rngError })
end
rng = nextRng
local selected = selectedLine.candidates[candidateIndex]
local sceneContext, sceneError = buildSceneContext(seed)
if sceneError then
return failure({ sceneError })
end
return success({
turnLimit = turnLimit,
sceneContext = sceneContext,
transit = {
algorithm = ALGORITHM,
lineId = selectedLine.lineId,
stationIds = copyArray(selected.stationIds),
},
receipt = {
routeSeed = rng.seed,
rngCursor = rng.cursor,
pathId = selected.pathId,
eligibleLineCount = #eligible,
candidateCount = #selectedLine.candidates,
},
})
end
local arguments = { ... }
if action == "build" then
return build(arguments[1], arguments[2], arguments[3])
end
return failure({
makeError("unknown_action", "$.action", "지원하지 않는 지하철 여정 작업입니다: " .. tostring(action)),
})
end)
#50triggerPipeline.lua
Keys:
(function(triggerId, action, ...)
local SCHEMA_VERSION = 1
local CATEGORY_ORDER = {
plan = 1,
trait = 2,
perk = 3,
}
local function makeError(code, path, message)
return {
code = code,
path = path,
message = message,
}
end
local function failure(errors)
return {
ok = false,
schemaVersion = SCHEMA_VERSION,
errors = errors,
}
end
local function success(state, transient, records)
return {
ok = true,
schemaVersion = SCHEMA_VERSION,
errors = {},
state = state,
transient = transient,
records = records,
}
end
local function isFinite(value)
return type(value) == "number"
and value == value
and value ~= math.huge
and value ~= -math.huge
end
local function isInteger(value, minimum)
return isFinite(value)
and value % 1 == 0
and (minimum == nil or value >= minimum)
end
local function isDenseArray(value)
if type(value) ~= "table" or getmetatable(value) ~= nil then
return false
end
local count = 0
local maximum = 0
for key in pairs(value) do
if not isInteger(key, 1) then
return false
end
count = count + 1
maximum = math.max(maximum, key)
end
return count == maximum
end
local function cloneData(value, path, active)
local valueType = type(value)
if valueType ~= "table" then
if valueType == "nil"
or valueType == "string"
or valueType == "number"
or valueType == "boolean" then
if valueType == "number" and not isFinite(value) then
return nil, makeError(
"non_finite_number",
path,
"유한하지 않은 숫자는 트리거 파이프라인 데이터에 복제할 수 없습니다."
)
end
return value, nil
end
return nil, makeError(
"unsupported_type",
path,
"트리거 파이프라인 데이터에 복제할 수 없는 자료형입니다: " .. valueType
)
end
if getmetatable(value) ~= nil then
return nil, makeError(
"metatable_not_allowed",
path,
"메타테이블이 있는 값은 트리거 파이프라인 데이터에 복제할 수 없습니다."
)
end
active = active or {}
if active[value] then
return nil, makeError(
"circular_reference",
path,
"순환 참조는 트리거 파이프라인 데이터에 복제할 수 없습니다."
)
end
active[value] = true
local copy = {}
for key, item in pairs(value) do
local keyCopy, keyError = cloneData(key, path .. ".<key>", active)
if keyError then
active[value] = nil
return nil, keyError
end
local itemCopy, itemError = cloneData(item, path .. "." .. tostring(key), active)
if itemError then
active[value] = nil
return nil, itemError
end
copy[keyCopy] = itemCopy
end
active[value] = nil
return copy, nil
end
local function normalizeStaticData(staticData)
if type(staticData) == "table" and type(staticData.data) == "table" then
return staticData.data
end
return staticData
end
local function appendErrors(target, nested)
if type(nested) ~= "table" then
target[#target + 1] = makeError(
"invalid_nested_error",
"$",
"하위 모듈 오류 목록이 올바르지 않습니다."
)
return
end
for _, item in ipairs(nested) do
target[#target + 1] = {
code = tostring(type(item) == "table" and item.code or "nested_error"),
path = tostring(type(item) == "table" and item.path or "$"),
message = tostring(
type(item) == "table" and item.message
or "하위 모듈 작업이 실패했습니다."
),
}
end
end
local function callModule(moduleName, moduleAction, ...)
if type(runScript) ~= "function" then
return nil, {
makeError(
"runtime_unavailable",
"$.runtime." .. moduleName,
"스크립트 실행기를 찾을 수 없습니다."
),
}
end
local ok, report = pcall(runScript, triggerId, moduleName, moduleAction, ...)
if not ok then
return nil, {
makeError(
"module_call_error",
"$.runtime." .. moduleName,
moduleName .. "." .. moduleAction .. " 실행에 실패했습니다: " .. tostring(report)
),
}
end
if type(report) ~= "table" then
return nil, {
makeError(
"invalid_module_result",
"$.runtime." .. moduleName,
"하위 모듈이 테이블 결과를 반환하지 않았습니다."
),
}
end
if report.ok ~= true then
local errors = {}
appendErrors(errors, report.errors)
return nil, errors
end
return report, nil
end
local function isSide(value)
return value == "player" or value == "character"
end
local function normalizeOptions(options, inputEvent)
if options == nil then
options = {}
end
if type(options) ~= "table" or getmetatable(options) ~= nil then
return nil, {
makeError("invalid_options", "$.options", "트리거 파이프라인 옵션은 일반 테이블이어야 합니다."),
}
end
local errors = {}
local allowed = {
phase = true,
currentCard = true,
insightSide = true,
allowGameplayCommands = true,
}
for key in pairs(options) do
if type(key) ~= "string" or not allowed[key] then
errors[#errors + 1] = makeError(
"unexpected_option",
"$.options." .. tostring(key),
"지원하지 않는 트리거 파이프라인 옵션입니다."
)
end
end
local phase = options.phase
if phase == nil and type(inputEvent) == "table" then
phase = inputEvent.type
end
if type(phase) ~= "string" or string.match(phase, "^[a-z][a-z0-9_]*$") == nil then
errors[#errors + 1] = makeError(
"invalid_phase",
"$.options.phase",
"phase는 lower_snake_case 문자열이어야 합니다."
)
end
if options.insightSide ~= nil and not isSide(options.insightSide) then
errors[#errors + 1] = makeError(
"invalid_insight_side",
"$.options.insightSide",
"insightSide는 player 또는 character여야 합니다."
)
end
if options.allowGameplayCommands ~= nil and type(options.allowGameplayCommands) ~= "boolean" then
errors[#errors + 1] = makeError(
"invalid_allow_gameplay_commands",
"$.options.allowGameplayCommands",
"allowGameplayCommands는 불리언이어야 합니다."
)
end
local currentCard = nil
if options.currentCard ~= nil then
local value = options.currentCard
if type(value) ~= "table" or getmetatable(value) ~= nil then
errors[#errors + 1] = makeError(
"invalid_current_card",
"$.options.currentCard",
"currentCard는 일반 테이블이어야 합니다."
)
else
local cardAllowed = {
id = true,
instanceId = true,
owner = true,
roles = true,
effectChoiceId = true,
}
for key in pairs(value) do
if type(key) ~= "string" or not cardAllowed[key] then
errors[#errors + 1] = makeError(
"unexpected_current_card_field",
"$.options.currentCard." .. tostring(key),
"currentCard에 허용되지 않은 필드가 있습니다."
)
end
end
if type(value.id) ~= "string" or value.id == "" then
errors[#errors + 1] = makeError(
"invalid_current_card_id",
"$.options.currentCard.id",
"현재 카드 ID가 없습니다."
)
end
if type(value.instanceId) ~= "string" or value.instanceId == "" then
errors[#errors + 1] = makeError(
"invalid_current_instance_id",
"$.options.currentCard.instanceId",
"현재 카드 인스턴스 ID가 없습니다."
)
end
if not isSide(value.owner) then
errors[#errors + 1] = makeError(
"invalid_current_card_owner",
"$.options.currentCard.owner",
"현재 카드 소유자는 player 또는 character여야 합니다."
)
end
if not isDenseArray(value.roles) or #value.roles < 1 then
errors[#errors + 1] = makeError(
"invalid_current_roles",
"$.options.currentCard.roles",
"현재 카드 역할이 올바르지 않습니다."
)
end
if value.effectChoiceId ~= nil
and (type(value.effectChoiceId) ~= "string"
or string.match(value.effectChoiceId, "^[a-z][a-z0-9_]*$") == nil) then
errors[#errors + 1] = makeError("invalid_effect_choice_id", "$.options.currentCard.effectChoiceId", "현재 카드 효과 선택값이 올바르지 않습니다.")
end
currentCard = {
id = value.id,
instanceId = value.instanceId,
owner = value.owner,
roles = value.roles,
effectChoiceId = value.effectChoiceId,
}
end
end
if #errors > 0 then
return nil, errors
end
return {
phase = phase,
currentCard = currentCard,
insightSide = options.insightSide,
allowGameplayCommands = options.allowGameplayCommands ~= false,
}, nil
end
local function validateInputs(staticData, working, inputEvent)
local errors = {}
staticData = normalizeStaticData(staticData)
if type(staticData) ~= "table"
or getmetatable(staticData) ~= nil
or type(staticData.registry) ~= "table"
or type(staticData.registry.events) ~= "table"
or type(staticData.cards) ~= "table"
or type(staticData.traits) ~= "table" then
errors[#errors + 1] = makeError(
"invalid_static_data",
"$.staticData",
"트리거 파이프라인에는 전체 정적 데이터가 필요합니다."
)
end
if type(working) ~= "table" or getmetatable(working) ~= nil then
errors[#errors + 1] = makeError(
"invalid_working",
"$.working",
"트리거 파이프라인 working 값은 일반 테이블이어야 합니다."
)
elseif type(working.state) ~= "table"
or getmetatable(working.state) ~= nil
or type(working.state.player) ~= "table"
or type(working.state.character) ~= "table"
or type(working.state.cardInstances) ~= "table" then
errors[#errors + 1] = makeError(
"invalid_working_state",
"$.working.state",
"트리거를 적용할 전투 상태가 올바르지 않습니다."
)
elseif working.transient ~= nil
and (type(working.transient) ~= "table" or getmetatable(working.transient) ~= nil) then
errors[#errors + 1] = makeError(
"invalid_transient",
"$.working.transient",
"트리거 transient 값은 일반 테이블이어야 합니다."
)
end
if type(inputEvent) ~= "table" or getmetatable(inputEvent) ~= nil then
errors[#errors + 1] = makeError(
"invalid_trigger_event",
"$.inputEvent",
"트리거 입력 사건은 일반 테이블이어야 합니다."
)
elseif type(staticData) == "table" and type(staticData.registry) == "table"
and type(staticData.registry.events) == "table" then
if type(inputEvent.type) ~= "string" or not staticData.registry.events[inputEvent.type] then
errors[#errors + 1] = makeError(
"unknown_trigger_event",
"$.inputEvent.type",
"등록되지 않은 트리거 입력 사건입니다."
)
end
if inputEvent.side ~= nil and not isSide(inputEvent.side) then
errors[#errors + 1] = makeError(
"invalid_event_side",
"$.inputEvent.side",
"입력 사건 진영은 player 또는 character여야 합니다."
)
end
end
if #errors > 0 then
return nil, errors
end
return staticData, nil
end
local function countZone(state, owner, zone)
local count = 0
for _, instance in ipairs(state.cardInstances) do
if type(instance) == "table" and instance.owner == owner and instance.zone == zone then
count = count + 1
end
end
return count
end
local function buildHistoryContext(history)
if type(runScript) ~= "function" then
error("battleHistory.context runtime is unavailable", 0)
end
local ok, report = pcall(runScript, triggerId, "battleHistory", "context", history)
if not ok or type(report) ~= "table" or report.ok ~= true or type(report.context) ~= "table" then
error("battleHistory.context failed", 0)
end
return report.context
end
local function buildContext(state, options, planState)
local intent = type(state.characterIntent) == "table" and state.characterIntent or {}
local context = {
turn = state.turnNumber,
phase = options.phase,
mood = state.character.mood,
history = buildHistoryContext(state.history),
player = {
stealth = state.player.stealth,
handCount = countZone(state, "player", "hand"),
},
character = {
resistance = state.character.resistance,
moodTokens = state.character.moodTokens,
publicRole = intent.publicRole,
},
}
if options.currentCard ~= nil then
context.card = {
id = options.currentCard.id,
instanceId = options.currentCard.instanceId,
owner = options.currentCard.owner,
roles = options.currentCard.roles,
}
if options.currentCard.effectChoiceId ~= nil then
context.effectChoiceId = options.currentCard.effectChoiceId
end
end
if planState ~= nil then
context.plan = {
cardId = planState.cardId,
cardInstanceId = planState.cardInstanceId,
side = planState.side,
revealed = planState.revealed,
slotIndex = planState.slotIndex,
}
if planState.remainingTurns ~= nil then
context.plan.remainingTurns = planState.remainingTurns
end
if planState.remainingCharges ~= nil then
context.plan.remainingCharges = planState.remainingCharges
end
if planState.effectChoiceId ~= nil then context.effectChoiceId = planState.effectChoiceId end
end
return context
end
local function sideRank(side, actingSide)
if actingSide ~= nil and side == actingSide then
return 1
end
if actingSide ~= nil and isSide(side) then
return 2
end
if actingSide == nil and side == "player" then
return 1
end
if actingSide == nil and side == "character" then
return 2
end
return 3
end
local function candidateSource(candidate)
local value = {
kind = candidate.kind,
id = candidate.sourceId,
}
if candidate.side ~= nil then
value.side = candidate.side
end
if candidate.planState ~= nil then
value.instanceId = candidate.planState.cardInstanceId
end
return value
end
local function appendRecord(records, recordType, candidate, payload)
local record = {
type = recordType,
source = candidateSource(candidate),
}
if candidate.side ~= nil then
record.side = candidate.side
end
if payload ~= nil then
record.payload = payload
end
records[#records + 1] = record
end
local function collectCandidates(staticData, snapshot, inputEvent, options)
local candidates = {}
local nextOrdinal = 1
local function addCandidate(kind, sourceId, ownerSide, declarationIndex, spec, planState, path)
if type(sourceId) ~= "string" or sourceId == "" then
return false, {
makeError("invalid_trigger_source", path, "트리거 source ID가 올바르지 않습니다."),
}
end
if ownerSide ~= nil and not isSide(ownerSide) then
return false, {
makeError("invalid_trigger_owner", path, "트리거 소유 진영이 올바르지 않습니다."),
}
end
if type(spec) ~= "table" or getmetatable(spec) ~= nil or type(spec.resolve) ~= "function" then
return false, {
makeError("invalid_trigger", path, "트리거 정의가 올바르지 않습니다: " .. sourceId),
}
end
candidates[#candidates + 1] = {
kind = kind,
categoryOrder = CATEGORY_ORDER[kind],
sourceId = sourceId,
side = ownerSide,
declarationIndex = declarationIndex or 1,
ordinal = nextOrdinal,
spec = spec,
planState = planState,
path = path,
}
nextOrdinal = nextOrdinal + 1
return true, nil
end
for _, planSide in ipairs({ "player", "character" }) do
local ownerState = snapshot[planSide]
local slots = type(ownerState) == "table" and ownerState.planSlots or nil
if not isDenseArray(slots) then
return nil, {
makeError(
"invalid_plan_slots",
"$.working.state." .. planSide .. ".planSlots",
"계획 슬롯 목록이 올바르지 않습니다."
),
}
end
for slotIndex, slot in ipairs(slots) do
if type(slot) ~= "table" or slot.occupied ~= true then
return nil, {
makeError(
"invalid_plan_slot",
"$.working.state." .. planSide .. ".planSlots[" .. slotIndex .. "]",
"점유 계획 슬롯이 올바르지 않습니다."
),
}
end
local card = staticData.cards[slot.cardId]
local planData = type(card) == "table"
and type(card.mechanismData) == "table"
and card.mechanismData.plan
or nil
local path = "$.staticData.cards." .. tostring(slot.cardId) .. ".mechanismData.plan"
if type(planData) ~= "table" then
return nil, {
makeError("missing_plan_definition", path, "계획 정의를 찾을 수 없습니다."),
}
end
local planState = {
side = planSide,
cardId = slot.cardId,
cardInstanceId = slot.cardInstanceId,
revealed = slot.revealed == true,
slotIndex = slotIndex,
}
if slot.remainingTurns ~= nil then
planState.remainingTurns = slot.remainingTurns
end
if slot.remainingCharges ~= nil then
planState.remainingCharges = slot.remainingCharges
end
if slot.effectChoiceId ~= nil then
planState.effectChoiceId = slot.effectChoiceId
end
local added, addErrors = addCandidate(
"plan",
slot.cardId,
planSide,
slotIndex,
planData,
planState,
path
)
if not added then
return nil, addErrors
end
end
end
local traitIds = snapshot.character.traitIds or {}
if not isDenseArray(traitIds) then
return nil, {
makeError("invalid_trait_ids", "$.working.state.character.traitIds", "특징 ID 목록이 배열이 아닙니다."),
}
end
for _, traitId in ipairs(traitIds) do
local trait = staticData.traits[traitId]
if type(trait) ~= "table" then
return nil, {
makeError("unknown_trait", "$.working.state.character.traitIds", "특징을 찾을 수 없습니다: " .. tostring(traitId)),
}
end
if trait.triggers ~= nil then
local path = "$.staticData.traits." .. tostring(traitId) .. ".triggers"
if not isDenseArray(trait.triggers) then
return nil, {
makeError("invalid_triggers", path, "특징 트리거가 배열이 아닙니다."),
}
end
for index, spec in ipairs(trait.triggers) do
local added, addErrors = addCandidate(
"trait",
traitId,
trait.owner,
index,
spec,
nil,
path .. "[" .. index .. "]"
)
if not added then
return nil, addErrors
end
end
end
end
local perkIds = snapshot.player.perkIds or {}
if not isDenseArray(perkIds) then
return nil, {
makeError("invalid_perk_ids", "$.working.state.player.perkIds", "퍽 ID 목록이 배열이 아닙니다."),
}
end
for _, perkId in ipairs(perkIds) do
local perk = type(staticData.perks) == "table" and staticData.perks[perkId] or nil
if type(perk) ~= "table" then
return nil, {
makeError("unknown_perk", "$.working.state.player.perkIds", "퍽을 찾을 수 없습니다: " .. tostring(perkId)),
}
end
if perk.triggers ~= nil then
local path = "$.staticData.perks." .. tostring(perkId) .. ".triggers"
if not isDenseArray(perk.triggers) then
return nil, {
makeError("invalid_triggers", path, "퍽 트리거가 배열이 아닙니다."),
}
end
for index, spec in ipairs(perk.triggers) do
local added, addErrors = addCandidate(
"perk",
perkId,
perk.owner or "player",
index,
spec,
nil,
path .. "[" .. index .. "]"
)
if not added then
return nil, addErrors
end
end
end
end
table.sort(candidates, function(left, right)
if left.categoryOrder ~= right.categoryOrder then
return left.categoryOrder < right.categoryOrder
end
local leftSide = sideRank(left.side, inputEvent.side)
local rightSide = sideRank(right.side, inputEvent.side)
if leftSide ~= rightSide then
return leftSide < rightSide
end
if left.sourceId ~= right.sourceId then
return left.sourceId < right.sourceId
end
if left.declarationIndex ~= right.declarationIndex then
return left.declarationIndex < right.declarationIndex
end
return left.ordinal < right.ordinal
end)
local matched = {}
for _, candidate in ipairs(candidates) do
local context = buildContext(snapshot, options, candidate.planState)
local conditionReport, conditionErrors = callModule(
"effectEngine",
"evaluateTriggerCondition",
staticData,
candidate.spec,
context,
inputEvent
)
if conditionErrors then
return nil, conditionErrors
end
if conditionReport.matched == true then
candidate.context = context
matched[#matched + 1] = candidate
end
end
return matched, nil
end
local function runPipeline(staticData, working, inputEvent, options)
local normalizedStaticData, inputErrors = validateInputs(staticData, working, inputEvent)
if inputErrors then
return failure(inputErrors)
end
local normalizedOptions, optionErrors = normalizeOptions(options, inputEvent)
if optionErrors then
return failure(optionErrors)
end
local state, cloneError = cloneData(working.state, "$.working.state")
if cloneError then
return failure({ cloneError })
end
local transient
transient, cloneError = cloneData(working.transient or {}, "$.working.transient")
if cloneError then
return failure({ cloneError })
end
local eventSnapshot
eventSnapshot, cloneError = cloneData(inputEvent, "$.inputEvent")
if cloneError then
return failure({ cloneError })
end
local stateSnapshot
stateSnapshot, cloneError = cloneData(state, "$.triggerSnapshot")
if cloneError then
return failure({ cloneError })
end
local candidates, candidateErrors = collectCandidates(
normalizedStaticData,
stateSnapshot,
eventSnapshot,
normalizedOptions
)
if candidateErrors then
return failure(candidateErrors)
end
local records = {}
for _, candidate in ipairs(candidates) do
local suppressed = normalizedOptions.insightSide ~= nil
and candidate.kind == "plan"
and candidate.side ~= normalizedOptions.insightSide
if suppressed then
appendRecord(records, "trigger_suppressed", candidate, {
inputEventType = eventSnapshot.type,
reasonCode = "insight",
hidden = candidate.planState.revealed ~= true,
})
else
local resolveReport, resolveErrors = callModule(
"effectEngine",
"evaluateTriggerResolve",
normalizedStaticData,
candidate.spec,
candidate.context,
eventSnapshot
)
if resolveErrors then
return failure(resolveErrors)
end
if not normalizedOptions.allowGameplayCommands and #resolveReport.commands > 0 then
return failure({
makeError(
"unsupported_session_end_commands",
candidate.path,
"게임플레이 명령이 금지된 트리거 batch에서 명령을 반환했습니다."
),
})
end
local beforePlanSlots = nil
if candidate.kind == "plan" then
beforePlanSlots, cloneError = cloneData(
state[candidate.side].planSlots,
"$.plan.before"
)
if cloneError then
return failure({ cloneError })
end
end
if #resolveReport.commands > 0 then
local applyReport, applyErrors = callModule(
"effectEngine",
"applyCommands",
normalizedStaticData,
{ state = state, transient = transient },
resolveReport.commands
)
if applyErrors then
return failure(applyErrors)
end
state = applyReport.state
transient = applyReport.transient
for _, applied in ipairs(applyReport.applied or {}) do
local payload
payload, cloneError = cloneData(applied, "$.records.effect_applied.payload")
if cloneError then
return failure({ cloneError })
end
appendRecord(records, "effect_applied", candidate, payload)
end
end
if candidate.kind == "plan" then
local zoneReport, zoneErrors = callModule(
"cardZones",
"consumePlanCharge",
state,
candidate.side,
candidate.planState.cardInstanceId
)
if zoneErrors then
return failure(zoneErrors)
end
state = zoneReport.state
local afterPlanSlots
afterPlanSlots, cloneError = cloneData(
state[candidate.side].planSlots,
"$.plan.after"
)
if cloneError then
return failure({ cloneError })
end
local movedInstanceIds
movedInstanceIds, cloneError = cloneData(
zoneReport.movedInstanceIds or {},
"$.plan.movedInstanceIds"
)
if cloneError then
return failure({ cloneError })
end
appendRecord(records, "plan_changed", candidate, {
action = "triggered",
before = beforePlanSlots,
after = afterPlanSlots,
discarded = #movedInstanceIds > 0,
movedInstanceIds = movedInstanceIds,
})
end
appendRecord(records, "trigger_resolved", candidate, {
inputEventType = eventSnapshot.type,
commandCount = #resolveReport.commands,
})
end
end
return success(state, transient, records)
end
local arguments = { ... }
local actions = {
run = runPipeline,
}
local handler = actions[action]
if not handler then
return failure({
makeError(
"unknown_action",
"$.action",
"지원하지 않는 트리거 파이프라인 작업입니다: " .. tostring(action)
),
})
end
return handler(arguments[1], arguments[2], arguments[3], arguments[4])
end)
Showing 50 of 59
Extensions: risuai (their contents are in the Raw tab)