등급 1~10 확장 + 라운드 주목도 하드 캡 재구현, 문서 체계 정비

서울 PC에서 Remote-SSH로 작업하던 중 연결이 끊겨 커밋 전 상태로 유실된
2026-09-15 결정사항(등급 1~4→1~10 확장, 5장 포커 핸드, 주목도 기반 페널티,
토큰 1종 통합, 상점/규약 재작성)을 평택 로컬 체크아웃에서 전체 재구현했다.
그 과정에서 라운드/회기 구조를 오해해서 잘못 반영했던 부분을 다시 고쳤다.

**데이터**
- cards.csv 43개 선택지, pollution_pool.csv 재매핑 — 등급 1·3 공백을 없애고
  1~10 전 구간을 채움 (평균 등급 4.14, 8결재 기준 예상 주목도 33)
- pollution_pool.csv에 등급 1·3 전용 오염 카드 8개 신규 추가 (축별 2개)

**게임 루프**
- 라운드/회기 이층 구조 도입 → 원래 의도(라운드마다 정산·의회를 거치는 것)와
  어긋난 오해로 판명, 단일 라운드 구조로 환원
- 라운드 종료 조건을 "결재 8건 고정"에서 블랙잭식 하드 캡으로 교체 —
  주목도가 라운드 한도(21 + 라운드당 +5)를 넘으면 합본 기회 없이 즉시 강제
  종료(버스트), 한도 밑에서는 "정산하기"로 언제든 자율 종료 가능
- CouncilScreen: 징벌 규약 5개 초과 시 목록 접기/펼치기 (§9.2 UI 요건)

**문서 체계**
- CLAUDE.md: 흩어진 기획 문서 7종 참조를 코어시스템 기획서 단일 참조(§ 매핑
  포함)로 교체, Phase 진행 표 폐지 후 §17 체크박스를 진행 상황 원본으로 지정
- CLAUDE.md에 "결정 노트" 규칙 신설 — T-decision.md 양식 + DEVLOG 위키링크 연동
- docs/DEVLOG.md 신규 작성 (구 버전은 old/2026.09.04/ 보관)
- 결정 노트 2건 작성: 등급확장-주목도시스템, 라운드-주목도-하드캡-복원
- ProjectSS_코어시스템_기획서.md §2/§4.2-B/§17/§19/§20을 현재 결정에 맞게 갱신

Playwright로 등급 1·3 카드 실등장, 라운드 번호 증가, 주목도 초과 시 버스트→
상점 강제 전환까지 실제 브라우저에서 확인. 콘솔 에러 0건.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kyoung5seo
2026-09-16 23:09:55 +09:00
co-authored by Claude Sonnet 5
parent cde44c4f2c
commit 0a065068c1
30 changed files with 1585 additions and 668 deletions
+70 -26
View File
@@ -1,19 +1,24 @@
// 의회 엔진 — ProjectSS_코어시스템_기획서.md §7(명분) · §9.2(규약) · §9.3(실적 감사).
// React 의존 없는 순수 함수 + 카탈로그. 13인 위원회의 실제 우호도·표결(§9.4~9.6)은
// 이중첩자·탄핵 방어까지 포함된 4차 범위라 아직 없다 — 그 대신 규약 후보 3개 중 1개를
// 뽑는 절차는 "가중 무작위"로 흉내만 낸다. §9.1 "규약 결정과 실적 감사는 독립적"이라는
// 뽑는 절차는 "무작위"로 흉내만 낸다. §9.1 "규약 결정과 실적 감사는 독립적"이라는
// 원칙만은 지킨다: 감사 결과가 어떤 규약이 뽑히는지에 영향을 주지 않는다.
//
// 2026-09-15 결정 반영: 토큰 1종 통합(§8.2), 명분 개입 4갈래 재정의(§7.3).
// 특히 내부 명분이 "실적 요구치 하향"에서 "후보 1개 강제 상정"으로 바뀌었다.
/* ── §9.3 실적 감사 ── */
// 요구치 곡선은 가안: 1R 25, 라운드당 +20.
// 요구치 곡선은 가안: 1라운드 25, 라운드당 +20 (§9.3).
export const requiredScore = (round) => 25 + (round - 1) * 20;
// 실적 점수 = Σ(족보 점수, 라운드 중 합본 누적) + 토큰 납입×5 + 명분 납입×20.
export const settleScore = ({ combineScore, tokensPaid, justificationsPaid }) =>
combineScore + tokensPaid * 5 + justificationsPaid * 20;
/* ── §9.2 규약 — 유형 A(족보표 변경) 우선, 유형 C(징벌)는 감사 실패 시 필요해서 최소 카탈로그만 ── */
/* ── §9.2 규약 — 유형 A(족보표 변경) 우선, 유형 C(징벌)는 감사 실패 시 필요해서 최소 카탈로그만.
* 유형 B(금리형)·D(이득)는 §20.2 "규약 상세 목록 — 소량 배치로 작성" 대상이라 아직 카탈로그가 없다.
* 유형 D가 없으면 내부 명분의 "강제 상정"이 전략적으로 빈약하다 — 이득 규약이 추가되면 살아난다. */
export const PROTOCOL_TYPE_A = [
{
@@ -40,11 +45,23 @@ export const PROTOCOL_TYPE_A = [
effect: { kind: 'disable_hand', patternId: 'four_kind' },
flavor: '동일 등급 4건 일괄 개편은 절차 생략 소지가 있어 당분간 금지합니다.',
},
{
id: 'protocol_fullhouse_ban',
name: '종합 감사 제한 규약',
effect: { kind: 'disable_hand', patternId: 'full_house' },
flavor: '3+2 형태의 묶음 상신은 감사 항목을 흐린다는 지적이 있었습니다.',
},
{
id: 'protocol_min_grade',
name: '기밀 등급 심사 강화 규약',
effect: { kind: 'min_grade', minGrade: 2 },
flavor: '일반 문서 단독으로는 더 이상 합본 상신이 승인되지 않습니다.',
effect: { kind: 'min_grade', minGrade: 4 },
flavor: '등급 4 미만 문서만으로는 더 이상 합본 상신이 승인되지 않습니다.',
},
{
id: 'protocol_pair_ban',
name: '중복 접수 금지 규약',
effect: { kind: 'disable_hand', patternId: 'pair' },
flavor: '두 건만 묶어 올리는 편의적 관행을 제한합니다. 최소 규모를 갖출 것.',
},
];
@@ -72,10 +89,17 @@ const pick = (arr, n) => {
return out;
};
/** 매 라운드 규약 후보 3개를 뽑는다 (유형 A만, §17 2차 범위). */
export const rollProtocolCandidates = () => pick(PROTOCOL_TYPE_A, 3);
/**
* 매 라운드 규약 후보 3개를 뽑는다 (유형 A만, §17 2차 범위).
* carriedOver: 절차 명분으로 지난 라운드에서 이월된 후보 — "지금은 피하지만 나중에 다시 온다"(§7.3).
*/
export const rollProtocolCandidates = (carriedOver = []) => {
const carriedIds = new Set(carriedOver.map((c) => c.id));
const fresh = pick(PROTOCOL_TYPE_A.filter((p) => !carriedIds.has(p.id)), Math.max(0, 3 - carriedOver.length));
return [...carriedOver, ...fresh];
};
/** 후보 중 1개를 "표결 통과"로 확정한다 — 실제 위원회가 없어 가중 무작위로 대신한다. */
/** 후보 중 1개를 "표결 통과"로 확정한다 — 실제 위원회가 없어 무작위로 대신한다. */
export const resolveProtocolVote = (candidates) => {
if (candidates.length === 0) return null;
return candidates[Math.floor(Math.random() * candidates.length)];
@@ -83,42 +107,62 @@ export const resolveProtocolVote = (candidates) => {
export const rollPenalty = () => pick(PROTOCOL_TYPE_C, 1)[0];
/* ── §7.2 명분 개입 — 규약 후보에 개입한다. 실제 13인 표결이 없어 "1표 뒤집기"(혼합 명분)는
* 문자 그대로 구현할 표가 없다. 대신 혼합 명분은 "후보 중 하나를 직접 지정 통과"로 대체했다 —
* 표 조작의 최종 효과(원하는 규약을 통과시킨다)는 같지만, 이건 §9.4~9.6의 실제 위원회가
* 생기면 반드시 다시 설계해야 할 자리표시자다. */
/** 아직 후보에 오르지 않은 규약들 — 내부 명분의 "강제 상정" 대상 목록. */
export const availableForTabling = (candidates) => {
const onBoard = new Set(candidates.map((c) => c.id));
return PROTOCOL_TYPE_A.filter((p) => !onBoard.has(p.id));
};
/* ── §7.3 ① 안건 조작 — 명분 4종 + 혼합 ──
* ② 정보 획득 · ③ 임원 직접 관리는 13인 위원회(§9.4~9.6, 4차)가 있어야 대상이 존재한다.
* ④ 실적 납입(명분 1개 = 20점)은 CouncilScreen에서 직접 처리한다.
*
* 혼합 명분의 "표결 결과 1표 뒤집기"는 실제 표가 없어 문자 그대로 구현할 수 없다.
* 최종 효과(원하는 규약이 통과된다)만 같게 "직접 통과 지정"으로 대체했고,
* 실제 위원회가 생기면 반드시 다시 설계해야 할 자리표시자다. */
export const JUSTIFICATION_INTERVENTIONS = {
절차: { label: '규약 이월 (이번 라운드 규약 보류)', needsTarget: false },
여론: { label: '후보 1개 강제 탈락', needsTarget: true },
위기: { label: '누적 징벌 규약 1개 조기 해제', needsTarget: 'penalty' },
내부: { label: '이번 라운드 실적 요구치 하향 (-20%)', needsTarget: false },
혼합: { label: '후보 1개 직접 통과 지정', needsTarget: true },
절차: { label: '후보 1개 다음 라운드로 이월', target: 'candidate' },
여론: { label: '후보 1개 강제 탈락 + 새 후보로 교체', target: 'candidate' },
위기: { label: '누적 징벌 규약 1개 조기 해제', target: 'penalty' },
내부: { label: '규약 1개 강제 상정', target: 'pool' },
혼합: { label: '후보 1개 직접 통과 지정 (표 뒤집기)', target: 'candidate' },
};
/**
* 명분 개입을 규약 후보에 반영한다 (§7.2 규약 개입).
* interventions: [{ type: '절차'|'여론'|'위기'|'내부'|'혼합', targetCandidateId?, targetPenaltyId? }]
* @returns {{ candidates, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear }}
* 명분 개입을 규약 후보에 반영한다 (§7.3 ①).
* interventions: [{ type, targetCandidateId?, targetPenaltyId?, targetPoolProtocolId? }]
* @returns {{ candidates, forcedWinnerId, deferred, penaltyIdToClear }}
* deferred: 다음 라운드 후보로 이월할 규약 목록 (절차 명분)
*/
export function applyInterventions(candidates, interventions) {
let pool = [...candidates];
let forcedWinnerId = null;
let deferVote = false;
let requiredScoreMultiplier = 1;
const deferred = [];
let penaltyIdToClear = null;
interventions.forEach((iv) => {
if (iv.type === '절차') deferVote = true;
if (iv.type === '절차' && iv.targetCandidateId) {
const target = pool.find((c) => c.id === iv.targetCandidateId);
if (target) {
deferred.push(target);
pool = pool.filter((c) => c.id !== iv.targetCandidateId);
}
}
if (iv.type === '여론' && iv.targetCandidateId) {
pool = pool.filter((c) => c.id !== iv.targetCandidateId);
// 강제 탈락시킨 자리에 새 후보 1개를 무작위로 채운다 (§7.3).
const replacement = pick(availableForTabling(pool), 1)[0];
if (replacement) pool = [...pool, replacement];
}
if (iv.type === '내부' && iv.targetPoolProtocolId) {
const tabled = PROTOCOL_TYPE_A.find((p) => p.id === iv.targetPoolProtocolId);
if (tabled && !pool.some((c) => c.id === tabled.id)) pool = [...pool, tabled];
}
if (iv.type === '내부') requiredScoreMultiplier *= 0.8;
if (iv.type === '위기' && iv.targetPenaltyId) penaltyIdToClear = iv.targetPenaltyId;
// 혼합 명분: 실제 표 뒤집기가 아니라 "직접 통과 지정"으로 대체한 자리표시자 — 위 §7.2 주석 참고.
if (iv.type === '혼합' && iv.targetCandidateId) forcedWinnerId = iv.targetCandidateId;
});
return { candidates: pool, forcedWinnerId, deferVote, requiredScoreMultiplier, penaltyIdToClear };
return { candidates: pool, forcedWinnerId, deferred, penaltyIdToClear };
}
/* ── 합본 판정에 규약 효과를 얹는다 ── */