From c11da3530fe7dc7967b8c95981b83a777eba5118 Mon Sep 17 00:00:00 2001 From: Wickedness Date: Fri, 12 Jun 2026 23:16:12 +0900 Subject: [PATCH] Add character location presence data --- README.md | 1 + data/character_location_presence.json | 1008 +++++++++++++++++++++++++ scripts/validate_content.py | 73 +- 3 files changed, 1081 insertions(+), 1 deletion(-) create mode 100644 data/character_location_presence.json diff --git a/README.md b/README.md index c6b7d26..ec7dc80 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ - `data/characters.json`: 22명의 성인 여성 캐릭터와 각자의 루트 구조 - `data/locations.json`: 맵 클릭 지점, 주요 시간대, 우연한 만남 태그 +- `data/character_location_presence.json`: 캐릭터별 출현 가능 장소, 시간대, 등장 가중치 - `data/progression.json`: 만남에서 연인 전환까지의 공통 진행 규칙 - `data/character_assets.json`: 캐릭터 기본 이미지의 통과 상태와 파일 경로 - `docs/game_structure.md`: 게임 루프와 시스템 설계 메모 diff --git a/data/character_location_presence.json b/data/character_location_presence.json new file mode 100644 index 0000000..0f030a1 --- /dev/null +++ b/data/character_location_presence.json @@ -0,0 +1,1008 @@ +{ + "version": "0.1.0", + "description": "Character appearance candidates for map encounters. Weights are per-character relative chances after filtering by location, time slot, and relationship availability.", + "availability_values": [ + "always", + "after_first_encounter", + "after_contact", + "dating_or_later", + "lovers_or_later" + ], + "role_values": [ + "anchor", + "secondary", + "rare" + ], + "characters": [ + { + "character_id": "han_seoyun", + "locations": [ + { + "location_id": "cooking_studio", + "role": "anchor", + "weight": 35, + "time_slots": ["afternoon", "evening"], + "availability": "always", + "hook": "수업 준비, 재료 손질, 늦은 정리 시간에 자연스럽게 만난다." + }, + { + "location_id": "neighborhood_market", + "role": "anchor", + "weight": 25, + "time_slots": ["morning", "afternoon"], + "availability": "after_first_encounter", + "hook": "장보기와 계절 식재료 고르기 중 생활감 있는 대화가 열린다." + }, + { + "location_id": "riverside_park", + "role": "secondary", + "weight": 20, + "time_slots": ["afternoon", "evening"], + "availability": "dating_or_later", + "hook": "조용한 산책에서 관계의 안정감과 소유욕이 드러난다." + }, + { + "location_id": "downtown_cafe", + "role": "secondary", + "weight": 10, + "time_slots": ["morning", "afternoon"], + "availability": "after_contact", + "hook": "레시피 노트를 정리하거나 다음 수업 아이디어를 떠올린다." + }, + { + "location_id": "apartment_complex", + "role": "rare", + "weight": 10, + "time_slots": ["evening", "night"], + "availability": "lovers_or_later", + "hook": "집 앞에서 도시락이나 따뜻한 간식을 건네는 사적인 이벤트에 쓴다." + } + ] + }, + { + "character_id": "yu_harin", + "locations": [ + { + "location_id": "old_bookstore", + "role": "anchor", + "weight": 35, + "time_slots": ["afternoon", "evening"], + "availability": "always", + "hook": "헌책 코너에서 같은 책을 집으며 첫 만남과 조용한 재회를 만든다." + }, + { + "location_id": "book_cafe", + "role": "anchor", + "weight": 30, + "time_slots": ["afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "창가 자리와 독서 노트를 통해 취향과 속마음을 천천히 연다." + }, + { + "location_id": "riverside_park", + "role": "secondary", + "weight": 15, + "time_slots": ["afternoon", "evening"], + "availability": "dating_or_later", + "hook": "비 온 뒤 산책에서 보호받는 감각과 조심스러운 고백을 다룬다." + }, + { + "location_id": "downtown_cafe", + "role": "secondary", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "약속 전후로 짧은 차 한잔과 어색한 문자 이벤트를 넣기 좋다." + }, + { + "location_id": "university_quarter", + "role": "rare", + "weight": 10, + "time_slots": ["morning", "afternoon"], + "availability": "after_contact", + "hook": "도서 행사나 중고책 플리마켓에서 예상 밖으로 마주친다." + } + ] + }, + { + "character_id": "kang_mirae", + "locations": [ + { + "location_id": "office_district", + "role": "anchor", + "weight": 35, + "time_slots": ["morning", "evening", "night"], + "availability": "always", + "hook": "회의 전후, 야근, 업무 트러블에서 차갑고 선명한 첫인상을 준다." + }, + { + "location_id": "rooftop_lounge", + "role": "anchor", + "weight": 25, + "time_slots": ["evening", "night"], + "availability": "dating_or_later", + "hook": "야경과 협상 같은 대화로 주도권과 긴장감을 세운다." + }, + { + "location_id": "wine_bar", + "role": "secondary", + "weight": 20, + "time_slots": ["evening", "night"], + "availability": "after_contact", + "hook": "퇴근 뒤 한 잔에서 능력 검증과 감정의 균열을 함께 보여준다." + }, + { + "location_id": "boutique_studio", + "role": "secondary", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "브랜드 촬영 점검이나 현장 피드백 이벤트에 어울린다." + }, + { + "location_id": "downtown_cafe", + "role": "rare", + "weight": 10, + "time_slots": ["morning", "afternoon"], + "availability": "after_contact", + "hook": "짧은 업무 미팅처럼 시작해 사적인 관심이 새어 나온다." + } + ] + }, + { + "character_id": "jung_naeun", + "locations": [ + { + "location_id": "downtown_cafe", + "role": "anchor", + "weight": 40, + "time_slots": ["morning", "afternoon", "evening"], + "availability": "always", + "hook": "근무 중 주문 실수, 숨겨둔 친절, 툴툴대는 재회를 만든다." + }, + { + "location_id": "amusement_street", + "role": "anchor", + "weight": 25, + "time_slots": ["afternoon", "evening", "night"], + "availability": "after_first_encounter", + "hook": "인형 뽑기와 길거리 간식으로 장난과 질투를 빠르게 올린다." + }, + { + "location_id": "cinema", + "role": "secondary", + "weight": 20, + "time_slots": ["afternoon", "evening", "night"], + "availability": "dating_or_later", + "hook": "어두운 객석에서 부끄러움과 가까운 거리를 다룬다." + }, + { + "location_id": "university_quarter", + "role": "secondary", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "수업 끝난 사람들 사이에서 우연히 마주쳐 괜히 틱틱댄다." + }, + { + "location_id": "riverside_park", + "role": "rare", + "weight": 5, + "time_slots": ["evening", "night"], + "availability": "lovers_or_later", + "hook": "솔직해진 뒤의 짧은 산책과 숨길 수 없는 질투 이벤트에 쓴다." + } + ] + }, + { + "character_id": "baek_chaewon", + "locations": [ + { + "location_id": "rooftop_lounge", + "role": "anchor", + "weight": 30, + "time_slots": ["evening", "night"], + "availability": "always", + "hook": "초대장 착각, 파티 뒤 남은 시간, 공개적인 시선 속 유혹을 담당한다." + }, + { + "location_id": "fashion_street", + "role": "anchor", + "weight": 25, + "time_slots": ["afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "쇼윈도와 스타일링 선택지로 관찰과 질투의 경계를 만든다." + }, + { + "location_id": "wine_bar", + "role": "secondary", + "weight": 20, + "time_slots": ["evening", "night"], + "availability": "after_contact", + "hook": "농담과 진담이 섞이는 거리감 조절 이벤트에 맞다." + }, + { + "location_id": "boutique_studio", + "role": "secondary", + "weight": 15, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "행사 리허설, 드레스 피팅, 비공개 준비 장면에 활용한다." + }, + { + "location_id": "jazz_bar", + "role": "rare", + "weight": 10, + "time_slots": ["evening", "night"], + "availability": "dating_or_later", + "hook": "화려함을 내려놓고 낮은 조명 아래 진심을 흘린다." + } + ] + }, + { + "character_id": "oh_sera", + "locations": [ + { + "location_id": "flower_shop", + "role": "anchor", + "weight": 35, + "time_slots": ["morning", "afternoon"], + "availability": "always", + "hook": "꽃말, 주문 상담, 닫힌 가게 안쪽의 조용한 시간을 담당한다." + }, + { + "location_id": "lakeside_walk", + "role": "anchor", + "weight": 25, + "time_slots": ["afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "말보다 침묵이 오래 남는 산책과 상처 치유 장면에 쓴다." + }, + { + "location_id": "wine_bar", + "role": "secondary", + "weight": 20, + "time_slots": ["evening", "night"], + "availability": "dating_or_later", + "hook": "다시 사랑할 용기를 꺼내는 차분한 성인 데이트에 어울린다." + }, + { + "location_id": "riverside_park", + "role": "secondary", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "꽃 배달 뒤 잠깐의 휴식과 담담한 속마음을 배치한다." + }, + { + "location_id": "neighborhood_market", + "role": "rare", + "weight": 10, + "time_slots": ["morning", "afternoon"], + "availability": "after_contact", + "hook": "가게 장식용 재료를 고르며 생활감 있는 면을 보여준다." + } + ] + }, + { + "character_id": "moon_jia", + "locations": [ + { + "location_id": "amusement_street", + "role": "anchor", + "weight": 35, + "time_slots": ["afternoon", "evening", "night"], + "availability": "always", + "hook": "이벤트 준비, 분실물 소동, 길거리 게임으로 직진 에너지를 준다." + }, + { + "location_id": "university_quarter", + "role": "anchor", + "weight": 25, + "time_slots": ["morning", "afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "푸드트럭과 축제 홍보 현장에서 활기찬 일상 데이트를 만든다." + }, + { + "location_id": "riverside_park", + "role": "secondary", + "weight": 20, + "time_slots": ["afternoon", "evening", "night"], + "availability": "dating_or_later", + "hook": "밝은 에너지 뒤의 진심과 고백 전 긴장을 보여준다." + }, + { + "location_id": "downtown_cafe", + "role": "secondary", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "일정 사이 잠깐 쉬며 사진을 고르는 이벤트에 쓴다." + }, + { + "location_id": "global_lounge", + "role": "rare", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "국제 행사 스태프 지원 중 낯선 모습으로 등장한다." + } + ] + }, + { + "character_id": "lim_sohee", + "locations": [ + { + "location_id": "downtown_cafe", + "role": "anchor", + "weight": 30, + "time_slots": ["morning", "afternoon", "evening"], + "availability": "always", + "hook": "퇴근 전후 조심스러운 휴식과 작은 오해를 풀기 좋다." + }, + { + "location_id": "apartment_complex", + "role": "anchor", + "weight": 25, + "time_slots": ["morning", "evening", "night"], + "availability": "after_first_encounter", + "hook": "동네에서 자주 마주치며 보호 본능과 생활감을 쌓는다." + }, + { + "location_id": "lakeside_walk", + "role": "secondary", + "weight": 20, + "time_slots": ["afternoon", "evening"], + "availability": "dating_or_later", + "hook": "불안을 내려놓고 천천히 신뢰를 확인하는 산책에 맞다." + }, + { + "location_id": "book_cafe", + "role": "secondary", + "weight": 15, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "조용한 자리에서 검색하던 고민을 들키는 장면을 만든다." + }, + { + "location_id": "flower_shop", + "role": "rare", + "weight": 10, + "time_slots": ["morning", "afternoon"], + "availability": "after_contact", + "hook": "누군가에게 줄 작은 꽃을 고르며 다정한 취향을 보여준다." + } + ] + }, + { + "character_id": "cha_yuri", + "locations": [ + { + "location_id": "fashion_street", + "role": "anchor", + "weight": 30, + "time_slots": ["afternoon", "evening"], + "availability": "always", + "hook": "쇼윈도 앞 시선, 거리 촬영, 무심한 평가로 모델 이미지를 세운다." + }, + { + "location_id": "boutique_studio", + "role": "anchor", + "weight": 30, + "time_slots": ["afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "촬영 대기실과 피팅 현장에서 거리감 있는 긴장을 만든다." + }, + { + "location_id": "rooftop_lounge", + "role": "secondary", + "weight": 20, + "time_slots": ["evening", "night"], + "availability": "dating_or_later", + "hook": "야경 아래 무표정이 흔들리는 고급 데이트에 어울린다." + }, + { + "location_id": "wine_bar", + "role": "secondary", + "weight": 10, + "time_slots": ["evening", "night"], + "availability": "after_contact", + "hook": "공식 석상 뒤 짧은 한 잔에서 속내를 아주 조금 보여준다." + }, + { + "location_id": "office_district", + "role": "rare", + "weight": 10, + "time_slots": ["morning", "evening"], + "availability": "after_contact", + "hook": "광고 미팅 이동 중 편집된 이미지와 실제 모습을 대비시킨다." + } + ] + }, + { + "character_id": "shin_yerim", + "locations": [ + { + "location_id": "wine_bar", + "role": "anchor", + "weight": 30, + "time_slots": ["evening", "night"], + "availability": "always", + "hook": "공연 뒤 와인과 절제된 농담으로 클래식한 성숙함을 준다." + }, + { + "location_id": "jazz_bar", + "role": "anchor", + "weight": 25, + "time_slots": ["evening", "night"], + "availability": "after_first_encounter", + "hook": "음악 취향과 무대 뒤 긴장을 통해 우아한 관심을 드러낸다." + }, + { + "location_id": "old_bookstore", + "role": "secondary", + "weight": 20, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "악보와 오래된 공연 팸플릿을 보며 의외의 장난기를 보여준다." + }, + { + "location_id": "rooftop_lounge", + "role": "secondary", + "weight": 15, + "time_slots": ["evening", "night"], + "availability": "dating_or_later", + "hook": "세련된 초대와 차분한 시선으로 관계의 격을 올린다." + }, + { + "location_id": "lakeside_walk", + "role": "rare", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "dating_or_later", + "hook": "완벽한 무대 밖의 피로와 진심을 조용히 보여준다." + } + ] + }, + { + "character_id": "kim_doa", + "locations": [ + { + "location_id": "university_quarter", + "role": "anchor", + "weight": 35, + "time_slots": ["morning", "afternoon", "evening"], + "availability": "always", + "hook": "강의 사이, 동아리 홍보, 캠퍼스 축제에서 밝은 호기심을 준다." + }, + { + "location_id": "downtown_cafe", + "role": "anchor", + "weight": 25, + "time_slots": ["afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "과제와 수다를 핑계로 자주 부르는 친근한 만남에 쓴다." + }, + { + "location_id": "cinema", + "role": "secondary", + "weight": 20, + "time_slots": ["afternoon", "evening", "night"], + "availability": "dating_or_later", + "hook": "새로운 장르를 함께 보며 관계를 데이트로 선명하게 만든다." + }, + { + "location_id": "book_cafe", + "role": "secondary", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "공부 모임과 우연한 옆자리로 편한 대화를 만든다." + }, + { + "location_id": "riverside_park", + "role": "rare", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "dating_or_later", + "hook": "성인으로서의 미래 고민과 첫 진심 고백을 다룬다." + } + ] + }, + { + "character_id": "park_minseo", + "locations": [ + { + "location_id": "fitness_center", + "role": "anchor", + "weight": 40, + "time_slots": ["morning", "evening"], + "availability": "always", + "hook": "운동 지도, 기록 경쟁, 늦은 정리 시간으로 건강한 긴장감을 만든다." + }, + { + "location_id": "riverside_park", + "role": "anchor", + "weight": 20, + "time_slots": ["afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "러닝 코스와 스트레칭으로 경쟁심과 배려를 동시에 보여준다." + }, + { + "location_id": "downtown_cafe", + "role": "secondary", + "weight": 15, + "time_slots": ["morning", "afternoon", "evening"], + "availability": "after_contact", + "hook": "운동 후 단백질 메뉴나 가벼운 식단 농담으로 사적인 거리를 좁힌다." + }, + { + "location_id": "neighborhood_market", + "role": "secondary", + "weight": 15, + "time_slots": ["morning", "afternoon"], + "availability": "after_contact", + "hook": "식단 장보기와 건강한 생활 루틴을 보여주는 일상 이벤트에 쓴다." + }, + { + "location_id": "rooftop_lounge", + "role": "rare", + "weight": 10, + "time_slots": ["evening", "night"], + "availability": "lovers_or_later", + "hook": "운동복이 아닌 세련된 모습으로 최종 루트 분위기를 전환한다." + } + ] + }, + { + "character_id": "seo_irin", + "locations": [ + { + "location_id": "art_studio", + "role": "anchor", + "weight": 40, + "time_slots": ["afternoon", "evening", "night"], + "availability": "always", + "hook": "작업실 조명, 물감 냄새, 새벽까지 남은 스케치로 감정선을 만든다." + }, + { + "location_id": "old_bookstore", + "role": "anchor", + "weight": 20, + "time_slots": ["afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "낡은 화집과 독특한 취향을 통해 몽환적인 거리를 유지한다." + }, + { + "location_id": "riverside_park", + "role": "secondary", + "weight": 20, + "time_slots": ["afternoon", "evening", "night"], + "availability": "dating_or_later", + "hook": "스케치 대상이 풍경에서 주인공으로 바뀌는 순간을 만든다." + }, + { + "location_id": "book_cafe", + "role": "secondary", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "마감 회피와 조용한 대화로 불안정한 매력을 보여준다." + }, + { + "location_id": "apartment_complex", + "role": "rare", + "weight": 10, + "time_slots": ["evening", "night"], + "availability": "lovers_or_later", + "hook": "늦은 밤 작업물을 들고 찾아오는 사적인 after-story에 쓴다." + } + ] + }, + { + "character_id": "yoon_gayoung", + "locations": [ + { + "location_id": "apartment_complex", + "role": "anchor", + "weight": 30, + "time_slots": ["morning", "evening", "night"], + "availability": "always", + "hook": "집 앞 마주침, 편한 옷차림, 오래된 친근감으로 친구 루트를 연다." + }, + { + "location_id": "downtown_cafe", + "role": "anchor", + "weight": 25, + "time_slots": ["morning", "afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "가벼운 수다와 갑작스러운 진심이 섞이는 반복 만남에 맞다." + }, + { + "location_id": "riverside_park", + "role": "secondary", + "weight": 20, + "time_slots": ["afternoon", "evening", "night"], + "availability": "dating_or_later", + "hook": "친구와 연인 사이의 선을 넘는 산책 이벤트를 만든다." + }, + { + "location_id": "amusement_street", + "role": "secondary", + "weight": 15, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "장난스러운 게임과 익숙한 웃음으로 편한 호감을 쌓는다." + }, + { + "location_id": "neighborhood_market", + "role": "rare", + "weight": 10, + "time_slots": ["morning", "afternoon"], + "availability": "after_contact", + "hook": "동네 장보기 중 오래된 사이처럼 자연스럽게 끼어든다." + } + ] + }, + { + "character_id": "choi_ria", + "locations": [ + { + "location_id": "boutique_studio", + "role": "anchor", + "weight": 35, + "time_slots": ["afternoon", "evening"], + "availability": "always", + "hook": "촬영, 라이브 준비, 이미지 관리로 카메라 앞의 매력을 세운다." + }, + { + "location_id": "fashion_street", + "role": "anchor", + "weight": 25, + "time_slots": ["afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "트렌드 쇼핑과 공개 시선 속에서 질투와 호기심을 만든다." + }, + { + "location_id": "rooftop_lounge", + "role": "secondary", + "weight": 20, + "time_slots": ["evening", "night"], + "availability": "dating_or_later", + "hook": "인플루언서 이미지 뒤의 사적인 불안을 꺼내기 좋다." + }, + { + "location_id": "downtown_cafe", + "role": "secondary", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "짧은 콘텐츠 촬영과 예상 밖의 솔직한 대화를 연결한다." + }, + { + "location_id": "global_lounge", + "role": "rare", + "weight": 10, + "time_slots": ["afternoon", "evening", "night"], + "availability": "after_contact", + "hook": "해외 브랜드 협업 미팅으로 더 넓은 이미지를 보여준다." + } + ] + }, + { + "character_id": "hong_sua", + "locations": [ + { + "location_id": "jazz_bar", + "role": "anchor", + "weight": 40, + "time_slots": ["evening", "night"], + "availability": "always", + "hook": "마감 직전 손님, 음악 취향, 바 카운터의 낮은 조명으로 시작한다." + }, + { + "location_id": "wine_bar", + "role": "anchor", + "weight": 25, + "time_slots": ["evening", "night"], + "availability": "after_first_encounter", + "hook": "운영자가 아닌 사적인 사람으로 마주하는 성숙한 데이트에 맞다." + }, + { + "location_id": "lakeside_walk", + "role": "secondary", + "weight": 15, + "time_slots": ["afternoon", "evening", "night"], + "availability": "dating_or_later", + "hook": "바깥 공기와 조용한 산책으로 책임감 뒤의 외로움을 보여준다." + }, + { + "location_id": "rooftop_lounge", + "role": "secondary", + "weight": 10, + "time_slots": ["evening", "night"], + "availability": "after_contact", + "hook": "업계 모임이나 손님 초대에서 여유와 경계를 동시에 드러낸다." + }, + { + "location_id": "downtown_cafe", + "role": "rare", + "weight": 10, + "time_slots": ["morning", "afternoon"], + "availability": "after_contact", + "hook": "밤의 얼굴과 다른 낮의 피곤하고 부드러운 모습을 보여준다." + } + ] + }, + { + "character_id": "lee_bom", + "locations": [ + { + "location_id": "flower_shop", + "role": "anchor", + "weight": 40, + "time_slots": ["morning", "afternoon"], + "availability": "always", + "hook": "꽃다발 실수, 손님 응대, 계절 꽃으로 밝은 첫인상을 준다." + }, + { + "location_id": "riverside_park", + "role": "anchor", + "weight": 20, + "time_slots": ["afternoon", "evening"], + "availability": "dating_or_later", + "hook": "피크닉과 산책을 통해 부드러움 속의 고집을 보여준다." + }, + { + "location_id": "downtown_cafe", + "role": "secondary", + "weight": 20, + "time_slots": ["morning", "afternoon"], + "availability": "after_first_encounter", + "hook": "꽃 배달 후 쉬어 가며 밝은 수다와 작은 질투를 만든다." + }, + { + "location_id": "neighborhood_market", + "role": "secondary", + "weight": 10, + "time_slots": ["morning", "afternoon"], + "availability": "after_contact", + "hook": "가게 비품을 사러 나와 생활력과 서툰 계산을 보여준다." + }, + { + "location_id": "book_cafe", + "role": "rare", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "꽃말 책을 찾다가 예상보다 진지한 면을 보여준다." + } + ] + }, + { + "character_id": "go_eunjae", + "locations": [ + { + "location_id": "office_district", + "role": "anchor", + "weight": 35, + "time_slots": ["morning", "evening", "night"], + "availability": "always", + "hook": "계약서, 비 오는 퇴근길, 차가운 검토로 첫인상을 만든다." + }, + { + "location_id": "wine_bar", + "role": "anchor", + "weight": 25, + "time_slots": ["evening", "night"], + "availability": "after_first_encounter", + "hook": "논리로 설명할 수 없는 흔들림과 절제된 긴장을 보여준다." + }, + { + "location_id": "book_cafe", + "role": "secondary", + "weight": 20, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "자료 조사와 관계론 대화로 감정의 비용을 계산하게 한다." + }, + { + "location_id": "downtown_cafe", + "role": "secondary", + "weight": 10, + "time_slots": ["morning", "afternoon"], + "availability": "after_contact", + "hook": "짧은 약속과 정확한 시간 감각으로 차가운 배려를 드러낸다." + }, + { + "location_id": "rooftop_lounge", + "role": "rare", + "weight": 10, + "time_slots": ["evening", "night"], + "availability": "dating_or_later", + "hook": "공식 자리 뒤 감정을 숨기지 못하는 고급 이벤트에 쓴다." + } + ] + }, + { + "character_id": "ryu_haena", + "locations": [ + { + "location_id": "fashion_street", + "role": "anchor", + "weight": 35, + "time_slots": ["afternoon", "evening"], + "availability": "always", + "hook": "네일 샘플, 쇼핑, 커플 컬러 제안으로 달콤한 소유욕을 세운다." + }, + { + "location_id": "downtown_cafe", + "role": "anchor", + "weight": 25, + "time_slots": ["afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "커플 음료와 잦은 연락으로 귀여운 압박감을 만든다." + }, + { + "location_id": "rooftop_lounge", + "role": "secondary", + "weight": 20, + "time_slots": ["evening", "night"], + "availability": "dating_or_later", + "hook": "확인받고 싶은 마음과 경계 설정을 다루는 고조 장면에 맞다." + }, + { + "location_id": "boutique_studio", + "role": "secondary", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "네일 작업이나 촬영 협업으로 화려한 기술을 보여준다." + }, + { + "location_id": "wine_bar", + "role": "rare", + "weight": 10, + "time_slots": ["evening", "night"], + "availability": "lovers_or_later", + "hook": "달콤한 이미지 뒤의 불안을 솔직히 말하는 늦은 밤 이벤트에 쓴다." + } + ] + }, + { + "character_id": "nam_juhee", + "locations": [ + { + "location_id": "police_box", + "role": "anchor", + "weight": 40, + "time_slots": ["morning", "evening", "night"], + "availability": "always", + "hook": "분실물 신고, 순찰 전후, 책임감 있는 응대로 첫 만남을 만든다." + }, + { + "location_id": "fitness_center", + "role": "anchor", + "weight": 25, + "time_slots": ["morning", "evening"], + "availability": "after_first_encounter", + "hook": "체력 관리와 정면 승부로 신뢰와 긴장감을 동시에 올린다." + }, + { + "location_id": "riverside_park", + "role": "secondary", + "weight": 20, + "time_slots": ["afternoon", "evening", "night"], + "availability": "dating_or_later", + "hook": "순찰 동선과 산책이 겹치며 공적인 얼굴이 사적인 감정으로 바뀐다." + }, + { + "location_id": "office_district", + "role": "secondary", + "weight": 10, + "time_slots": ["morning", "evening", "night"], + "availability": "after_contact", + "hook": "민원 처리나 교통 지도 중 단단한 직업 정체성을 보여준다." + }, + { + "location_id": "downtown_cafe", + "role": "rare", + "weight": 5, + "time_slots": ["morning", "afternoon"], + "availability": "after_contact", + "hook": "근무 사이 짧은 휴식에서 예상보다 부드러운 표정을 보여준다." + } + ] + }, + { + "character_id": "marina", + "locations": [ + { + "location_id": "global_lounge", + "role": "anchor", + "weight": 35, + "time_slots": ["afternoon", "evening", "night"], + "availability": "always", + "hook": "통역 도움, 문화 차이 농담, 여행 일정으로 자유로운 첫인상을 준다." + }, + { + "location_id": "riverside_park", + "role": "anchor", + "weight": 20, + "time_slots": ["afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "언어 교환과 산책으로 서로의 표현 방식을 배운다." + }, + { + "location_id": "wine_bar", + "role": "secondary", + "weight": 20, + "time_slots": ["evening", "night"], + "availability": "dating_or_later", + "hook": "자유와 관계의 균형을 어른스럽게 이야기하는 데이트에 맞다." + }, + { + "location_id": "downtown_cafe", + "role": "secondary", + "weight": 15, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "짧은 번역 부탁과 여행 사진 공유로 쉽게 가까워진다." + }, + { + "location_id": "university_quarter", + "role": "rare", + "weight": 10, + "time_slots": ["afternoon", "evening"], + "availability": "after_contact", + "hook": "국제 교류 행사나 길거리 공연에서 활짝 열린 모습을 보여준다." + } + ] + }, + { + "character_id": "song_arin", + "locations": [ + { + "location_id": "apartment_complex", + "role": "anchor", + "weight": 35, + "time_slots": ["morning", "evening", "night"], + "availability": "always", + "hook": "이사, 깨진 소품, 집 앞 대화로 현실적인 두 번째 사랑을 연다." + }, + { + "location_id": "book_cafe", + "role": "anchor", + "weight": 25, + "time_slots": ["afternoon", "evening"], + "availability": "after_first_encounter", + "hook": "인테리어 책과 조용한 커피로 생활 취향을 공유한다." + }, + { + "location_id": "lakeside_walk", + "role": "secondary", + "weight": 20, + "time_slots": ["afternoon", "evening", "night"], + "availability": "dating_or_later", + "hook": "미래 약속에 대한 두려움과 다시 믿는 용기를 다룬다." + }, + { + "location_id": "flower_shop", + "role": "secondary", + "weight": 10, + "time_slots": ["morning", "afternoon"], + "availability": "after_contact", + "hook": "가게 인테리어용 꽃을 고르며 섬세한 감각을 보여준다." + }, + { + "location_id": "downtown_cafe", + "role": "rare", + "weight": 10, + "time_slots": ["morning", "afternoon", "evening"], + "availability": "after_contact", + "hook": "일상적인 약속에서 방어적인 농담과 따뜻한 배려가 함께 나온다." + } + ] + } + ] +} diff --git a/scripts/validate_content.py b/scripts/validate_content.py index 72a6d22..751492a 100644 --- a/scripts/validate_content.py +++ b/scripts/validate_content.py @@ -26,16 +26,21 @@ def main() -> None: characters_doc = load_json(DATA / "characters.json") locations_doc = load_json(DATA / "locations.json") progression_doc = load_json(DATA / "progression.json") + presence_path = DATA / "character_location_presence.json" + presence_doc = load_json(presence_path) if presence_path.exists() else None minimum_age = characters_doc["content_rules"]["minimum_age"] - locations = {location["id"] for location in locations_doc["locations"]} + location_docs = {location["id"]: location for location in locations_doc["locations"]} + locations = set(location_docs) character_ids: set[str] = set() + preferred_locations_by_character: dict[str, set[str]] = {} for character in characters_doc["characters"]: character_id = character["id"] if character_id in character_ids: fail(f"duplicate character id: {character_id}") character_ids.add(character_id) + preferred_locations_by_character[character_id] = set(character["preferred_locations"]) if character["age"] < minimum_age: fail( @@ -72,6 +77,72 @@ def main() -> None: if adult_requirements["minimum_character_age"] != minimum_age: fail("progression minimum age does not match character content rules") + if presence_doc is not None: + presence_ids: set[str] = set() + valid_availability = set(presence_doc["availability_values"]) + valid_roles = set(presence_doc["role_values"]) + + for entry in presence_doc["characters"]: + character_id = entry["character_id"] + if character_id not in character_ids: + fail(f"presence references missing character id: {character_id}") + if character_id in presence_ids: + fail(f"duplicate presence entry for character id: {character_id}") + presence_ids.add(character_id) + + seen_locations: set[str] = set() + total_weight = 0 + for candidate in entry["locations"]: + location_id = candidate["location_id"] + if location_id not in locations: + fail(f"{character_id} presence references missing location {location_id}") + if location_id in seen_locations: + fail(f"{character_id} has duplicate presence location {location_id}") + seen_locations.add(location_id) + + role = candidate["role"] + if role not in valid_roles: + fail(f"{character_id} has invalid presence role {role}") + + availability = candidate["availability"] + if availability not in valid_availability: + fail(f"{character_id} has invalid availability {availability}") + + weight = candidate["weight"] + if not isinstance(weight, int) or weight <= 0: + fail(f"{character_id} has invalid presence weight {weight}") + total_weight += weight + + location_time_slots = set(location_docs[location_id]["time_slots"]) + for time_slot in candidate["time_slots"]: + if time_slot not in location_time_slots: + fail( + f"{character_id} uses invalid time slot {time_slot} " + f"for location {location_id}" + ) + + if not candidate["hook"].strip(): + fail(f"{character_id} presence location {location_id} lacks hook text") + + if total_weight != 100: + fail(f"{character_id} presence weights must sum to 100, got {total_weight}") + + missing_preferred = preferred_locations_by_character[character_id].difference( + seen_locations + ) + if missing_preferred: + fail( + f"{character_id} presence lacks preferred locations: " + f"{sorted(missing_preferred)}" + ) + + if presence_ids != character_ids: + fail( + "presence character mismatch; " + f"missing={sorted(character_ids.difference(presence_ids))}, " + f"extra={sorted(presence_ids.difference(character_ids))}" + ) + print( "Validated " f"{len(character_ids)} characters, "