등급 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 };
}
/* ── 합본 판정에 규약 효과를 얹는다 ── */
+71 -38
View File
@@ -1,16 +1,23 @@
// 책상(Desk) 엔진 — ProjectSS_코어시스템_기획서.md §4.
// 오염 카드는 덱에 들어가지 않는다. 드로우·셔플 없음, 슬롯 무제한. 여기서 다루는 desk는
// 순수 배열 [{ id, axis, grade }] 이며, React state는 이 배열을 그대로 들고 있다가
// 이 모듈의 함수로 파생값(축별 스택, 패널티)을 계산한다. React/훅 의존 없음 — 순수 함수만.
// 오염 카드는 덱에 들어가지 않는다. 드로우·셔플 개념 없음, 슬롯 무제한. 여기서 다루는 desk는
// 순수 배열 [{ id, axis, grade, name, flavor, tag }]이며, React state는 이 배열을 그대로 들고
// 있다가 이 모듈의 함수로 파생값(주목도, 페널티)을 계산한다. React/훅 의존 없음 — 순수 함수만.
//
// 2026-09-15 결정 반영: 페널티 기준을 "카드 장수" → "등급 합계"로 전환하고 이를 주목도라 부른다.
// 등급 편차가 1~10으로 커진 만큼, 장수만 세면 특급 기밀과 일반 문서가 똑같이 취급돼 압박이
// 정직하지 않다. 등급 자체가 곧 가중치이므로 별도 가중치 테이블은 없다.
//
// 이월(§4.1): 합본에 사용되지 않은 카드는 소멸하지 않고 책상에 남는다. 별도 이월 시스템이
// 없는 이유가 바로 이것 — "합본이 닿은 카드만 사라진다"는 규칙 하나로 자연히 성립한다.
import { AXES } from './symbols.js';
import { AXES, AXIS_META, clampGrade } from './symbols.js';
let seq = 0;
// meta: pollutionPool.js에서 뽑은 { name, flavor, tag } — 책상 카드가 실제 이름/사연을 갖게 한다.
export const makeDeskCard = (axis, grade, meta = {}) => ({
id: `desk-${Date.now().toString(36)}-${(seq++).toString(36)}`,
axis,
grade,
grade: clampGrade(grade),
name: meta.name || null,
flavor: meta.flavor || null,
tag: meta.tag || null,
@@ -23,7 +30,7 @@ export function countByAxis(deskCards) {
return out;
}
/** 축별 등급 분포. { axis_threat: {1: 2, 3: 1}, ... } — DeskBar의 축×등급 매트릭스 표시용. */
/** 축별 등급 분포. { axis_threat: {7: 2, 3: 1}, ... } — DeskBar의 축×등급 표시용. */
export function gradeBreakdownByAxis(deskCards) {
const out = Object.fromEntries(AXES.map((a) => [a, {}]));
deskCards.forEach((c) => {
@@ -34,41 +41,50 @@ export function gradeBreakdownByAxis(deskCards) {
export const totalCount = (deskCards) => deskCards.length;
/* ── A. 게이지 상시 상승 (§4.2-A, 플레이테스트 피드백 반영 재조정) ──
* 최초 구현은 "장수"만 셌는데, 1급 기밀이나 일반 문서나 똑같이 취급돼 위기감이 없었다.
* 그래서 장수 대신 등급 가중 "오염 압력"(P)을 쓴다: 등급1=1, 등급2=2, 등급3=3, 등급4=5.
* 구간도 초반 체감이 되도록 올렸다 — 정확한 수치는 가안, 플레이테스트로 계속 조정한다.
*/
const GRADE_WEIGHT = { 1: 1, 2: 2, 3: 3, 4: 5 };
/* ── 주목도 (§4.2) — 페널티 산정의 단일 기준 ── */
/** 해당 축의 오염 압력(P) = Σ 카드별 등급 가중치. */
export function axisPressure(deskCards, axis) {
/** 해당 축의 주목도 = 그 축 카드들의 등급 합. */
export function axisAttention(deskCards, axis) {
return deskCards
.filter((c) => c.axis === axis)
.reduce((sum, c) => sum + (GRADE_WEIGHT[c.grade] || 0), 0);
.reduce((sum, c) => sum + c.grade, 0);
}
/** 책상 전체 주목도 = 모든 카드의 등급 합. */
export function totalAttention(deskCards) {
return deskCards.reduce((sum, c) => sum + c.grade, 0);
}
export function attentionByAxis(deskCards) {
const out = {};
AXES.forEach((axis) => { out[axis] = axisAttention(deskCards, axis); });
return out;
}
/* ── A. 게이지 상시 상승 (§4.2-A) — 축별 주목도 기준 누진 ── */
const GAUGE_TIERS = [
{ max: 0, rate: 0 },
{ max: 2, rate: 1.0 },
{ max: 6, rate: 2.0 },
{ max: 12, rate: 3.5 },
{ max: 20, rate: 6.0 },
{ max: 30, rate: 9.0 },
{ max: Infinity, rate: 13.0 },
{ max: 15, rate: 0.5 },
{ max: 30, rate: 1.5 },
{ max: 40, rate: 3.0 },
];
const GAUGE_OVERRUN_FROM = 41;
const GAUGE_OVERRUN_BASE = 5.0;
/** 오염 압력(P)에 따른 결재 1건당 게이지 상승률(%). */
export function gaugeRiseRate(pressure) {
const tier = GAUGE_TIERS.find((t) => pressure <= t.max);
return tier ? tier.rate : 13.0;
/** 축 주목도에 따른 결재 1건당 게이지 상승률(%). 41 이상은 초과분 10당 +1.0% 추가 누진. */
export function gaugeRiseRate(attention) {
const tier = GAUGE_TIERS.find((t) => attention <= t.max);
if (tier) return tier.rate;
const overrun = attention - GAUGE_OVERRUN_FROM;
return GAUGE_OVERRUN_BASE + Math.floor(overrun / 10) * 1.0;
}
/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다.
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyGaugeSurcharge). */
export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) {
const out = {};
AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisPressure(deskCards, axis)) + extraFlat; });
AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisAttention(deskCards, axis)) + extraFlat; });
return out;
}
@@ -76,31 +92,48 @@ export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) {
export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
const next = { ...gauges };
AXES.forEach((axis) => {
const gaugeKey = axisGaugeKey(axis);
const rate = gaugeRiseRate(axisPressure(deskCards, axis)) + extraFlat;
const gaugeKey = AXIS_META[axis].gauge;
const rate = gaugeRiseRate(axisAttention(deskCards, axis)) + extraFlat;
next[gaugeKey] = Math.min(100, (next[gaugeKey] || 0) + rate);
});
return next;
}
// symbols.js의 AXIS_META를 다시 import하면 순환 우려가 없으므로 바로 가져와 쓴다.
import { AXIS_META } from './symbols.js';
function axisGaugeKey(axis) {
return AXIS_META[axis].gauge;
/* ── B. 라운드 종료 판단 (§4.2-B, 2026-09-16 결정 재수정) — 블랙잭식 하드 캡.
* 라운드당 주목도 한도를 넘기면 그 즉시 라운드가 강제 종료된다(합본 기회 없이, 카드는 이월).
* 한도 자체가 라운드가 갈수록 올라가 후반에는 더 크게 쌓아도 되는 대신 압박도 커진다.
* 한도 밑에서는 플레이어가 아무 때나 자율적으로 라운드를 끝내고 정산으로 넘어갈 수 있다 —
* "한 장 더 받을까 말까"의 긴장은 여기서 나온다. 결정 노트: 2026-09-16-라운드-주목도-하드캡-복원. */
const ROUND_CAP_BASE = 21; // 가안 — 최초 블랙잭 기준으로 복귀
const ROUND_CAP_GROWTH = 5; // 라운드당 가산분, 가안
/** 라운드 번호에 따른 이번 라운드의 주목도 한도. */
export function roundAttentionCap(round) {
return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH;
}
/* ── B. 토큰 지출 가산 (§4.2-B) — 총 적재 장수에 따라 모든 토큰 지출에 가산 ── */
/** 한도 대비 총 주목도 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과). */
export function attentionZone(deskCards, cap) {
const total = totalAttention(deskCards);
if (total > cap) return 'bust';
if (total >= cap - 5) return 'warning';
return 'normal';
}
/* ── C. 토큰 지출 가산 (§4.2-C) — 총 주목도 기준 ── */
const SURCHARGE_TIERS = [
{ max: 7, surcharge: 0 },
{ max: 14, surcharge: 1 },
{ max: 21, surcharge: 2 },
{ max: 20, surcharge: 0 },
{ max: 40, surcharge: 1 },
{ max: 60, surcharge: 2 },
{ max: Infinity, surcharge: 4 },
];
/** 총 적재 장수 기준 모든 토큰 지출에 붙는 가산액.
/** 총 주목도 기준 모든 토큰 지출에 붙는 가산액.
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyTokenSurcharge). */
export function tokenSurcharge(deskCards, extraFlat = 0) {
const total = totalCount(deskCards);
const total = totalAttention(deskCards);
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
return (tier ? tier.surcharge : 4) + extraFlat;
}
+147 -69
View File
@@ -1,106 +1,184 @@
// 합본(족보) 판정 · 실행 엔진 — ProjectSS_코어시스템_기획서.md §5, §7.1, §8.2.
// 트럼프 족보 구조를 그대로 차용한다 (§5.1 설계 원칙). React/훅 의존 없는 순수 함수.
//
// 문서 내부 불일치 메모: §7.1은 "수량 3장=1/4장=2/5장 이상=3"이라는 일반 공식을 제시하지만
// §5.1 표의 실제 명분 값(예: 풀하우스 2, 포카드 3, 스트레이트 플러시 3)과 맞지 않는다.
// 여기서는 §5.1 표의 고정값을 그대로 채택했다 — 구체적인 족보표가 일반 공식보다 우선한다고 봤다.
// 1급 기밀(등급4) 포함 시 +1 규칙만 §7.1에서 그대로 가져와 표 값 위에 얹었다.
// 2026-09-15 결정 반영 (3장 4등급 → 5장 10등급):
// - 족보를 5장 포커 핸드 8종으로 재정의. 투페어(이중 접수) 신설.
// - §5.1 "여러 장 쌓은 카드 중 최고의 5장을 뽑는 방식"에 맞춰, 판정 방식을 바꿨다.
// 구 구현은 "선택한 카드가 정확히 족보 크기와 일치해야 성립"하는 엄격 매칭이었지만,
// 이제는 선택 집합 안에서 족보를 이루는 카드를 찾아낸다. 그래서 5장을 골라도 페어만
// 성립하면 페어로 상신할 수 있고, 그 경우 족보에 실제로 쓰인 2장만 소멸한다
// (나머지는 §4.1 이월 원칙에 따라 책상에 남는다).
// - 토큰은 1종이고 산출량은 등급 합계다 (§8.2). 축은 명분 종류만 결정한다 (§7.2).
// - 명분 +1 보너스 기준이 "등급 4" → "등급 8 이상"으로 바뀌었다 (§7.1).
import { justificationOf, tokenOf } from './symbols.js';
import { justificationOf, TOP_SECRET_THRESHOLD } from './symbols.js';
export const MIN_COMBINE_CARDS = 2;
export const MAX_COMBINE_CARDS = 5;
export const HAND_DEFS = {
pair: { id: 'pair', name: '중복 접수', size: [2, 2], baseJustification: 0.5, score: 10 },
triple: { id: 'triple', name: '집단 민원', size: [3, 3], baseJustification: 1, score: 25 },
flush_axis: { id: 'flush_axis', name: '일괄 처리', size: [3, 3], baseJustification: 1, score: 25 },
straight: { id: 'straight', name: '단계적 상신', size: [3, 3], baseJustification: 1, score: 25 },
full_house: { id: 'full_house', name: '종합 감사', size: [5, 5], baseJustification: 2, score: 50 },
four_kind: { id: 'four_kind', name: '전면 개편', size: [4, 4], baseJustification: 3, score: 80 },
straight_flush: { id: 'straight_flush', name: '전결 처리', size: [3, 4], baseJustification: 3, score: 80 },
pair: { id: 'pair', name: '중복 접수', cards: 2, baseJustification: 0.5, score: 10, condition: '같은 등급 2장' },
two_pair: { id: 'two_pair', name: '이중 접수', cards: 4, baseJustification: 1, score: 20, condition: '같은 등급 2장 × 2쌍' },
triple: { id: 'triple', name: '집단 민원', cards: 3, baseJustification: 1, score: 25, condition: '같은 등급 3장' },
straight: { id: 'straight', name: '단계적 상신', cards: 5, baseJustification: 2, score: 40, condition: '연속 등급 5장 (축 무관)' },
flush_axis: { id: 'flush_axis', name: '일괄 처리', cards: 5, baseJustification: 2, score: 40, condition: '같은 축 5장' },
full_house: { id: 'full_house', name: '종합 감사', cards: 5, baseJustification: 3, score: 60, condition: '같은 등급 3장 + 같은 등급 2장' },
four_kind: { id: 'four_kind', name: '전면 개편', cards: 4, baseJustification: 4, score: 90, condition: '같은 등급 4장' },
straight_flush: { id: 'straight_flush', name: '전결 처리', cards: 5, baseJustification: 5, score: 150, condition: '같은 축 + 연속 등급 5장' },
};
// 족보 우선순위(높을수록 상위) — 동시 성립 시 기본 선택값과 정렬 순서에 쓴다.
const HAND_RANK = ['pair', 'triple', 'flush_axis', 'straight', 'full_house', 'four_kind', 'straight_flush'];
// 족보 우선순위(뒤로 갈수록 상위). 실적 점수 순이며, straight와 flush_axis가 동점(40)일 때는
// 트럼프 관례대로 플러시를 상위로 둔다.
const HAND_RANK = [
'pair', 'two_pair', 'triple', 'straight', 'flush_axis', 'full_house', 'four_kind', 'straight_flush',
];
const sameGrade = (cards) => cards.every((c) => c.grade === cards[0].grade);
const sameAxis = (cards) => cards.every((c) => c.axis === cards[0].axis);
const sumGrades = (cards) => cards.reduce((s, c) => s + c.grade, 0);
/** 등급이 중복 없이 연속 오름차순인가 (예: 1,2,3 / 2,3,4). §5.1 "등급 오름차순". */
function isAscendingRun(cards) {
const grades = [...cards.map((c) => c.grade)].sort((a, b) => a - b);
if (new Set(grades).size !== grades.length) return false;
for (let i = 1; i < grades.length; i++) {
if (grades[i] !== grades[i - 1] + 1) return false;
}
return true;
/** 등급 → 그 등급의 카드 배열. */
function groupByGrade(cards) {
const m = new Map();
cards.forEach((c) => {
if (!m.has(c.grade)) m.set(c.grade, []);
m.get(c.grade).push(c);
});
return m;
}
function gradeCounts(cards) {
const m = {};
cards.forEach((c) => { m[c.grade] = (m[c.grade] || 0) + 1; });
return Object.values(m).sort((a, b) => b - a);
/** 축 → 그 축의 카드 배열. */
function groupByAxis(cards) {
const m = new Map();
cards.forEach((c) => {
if (!m.has(c.axis)) m.set(c.axis, []);
m.get(c.axis).push(c);
});
return m;
}
/** 등급이 n장 이상 모인 등급들을 내림차순으로 (등급 합을 최대화하려면 높은 등급이 유리). */
function gradesWithAtLeast(byGrade, n) {
return [...byGrade.entries()]
.filter(([, cards]) => cards.length >= n)
.map(([grade]) => grade)
.sort((a, b) => b - a);
}
/** 연속 등급 5장을 찾는다. 중복 등급은 한 장만 쓴다. 등급 합이 가장 큰 구간을 고른다. */
function findRun(cards, length = 5) {
const byGrade = groupByGrade(cards);
const grades = [...byGrade.keys()].sort((a, b) => a - b);
let best = null;
for (let i = 0; i + length - 1 < grades.length; i++) {
const window = grades.slice(i, i + length);
const isConsecutive = window.every((g, k) => k === 0 || g === window[k - 1] + 1);
if (!isConsecutive) continue;
// 각 등급에서 한 장씩 (같은 등급이 여러 장이면 아무거나 — 등급이 같으므로 합도 같다)
const picked = window.map((g) => byGrade.get(g)[0]);
if (!best || sumGrades(picked) > sumGrades(best)) best = picked;
}
return best;
}
/**
* 선택된 책상 카드 묶음이 어떤 족보(들)로 성립하는지 판정한다.
* 여러 족보가 동시 성립할 수 있다 (§5.3) — 예: 같은 축 + 연속 등급 3장은
* flush_axis / straight / straight_flush 세 가지 모두로 성립하며, 플레이어가 하나를 고른다.
* @returns {string[]} 성립하는 HAND_DEFS id 배열, 실적 점수 내림차순 정렬
* 선택 집합에서 지정 족보를 이루는 카드들을 찾는다. 성립하지 않으면 null.
* 여러 조합이 가능하면 등급 합이 큰 쪽을 고른다 — 토큰 산출이 등급 합계이므로 그게 이득이다.
*/
export function findHandCards(selection, patternId) {
const byGrade = groupByGrade(selection);
switch (patternId) {
case 'pair': {
const g = gradesWithAtLeast(byGrade, 2)[0];
return g === undefined ? null : byGrade.get(g).slice(0, 2);
}
case 'triple': {
const g = gradesWithAtLeast(byGrade, 3)[0];
return g === undefined ? null : byGrade.get(g).slice(0, 3);
}
case 'four_kind': {
const g = gradesWithAtLeast(byGrade, 4)[0];
return g === undefined ? null : byGrade.get(g).slice(0, 4);
}
case 'two_pair': {
const pairs = gradesWithAtLeast(byGrade, 2);
if (pairs.length < 2) return null;
return [...byGrade.get(pairs[0]).slice(0, 2), ...byGrade.get(pairs[1]).slice(0, 2)];
}
case 'full_house': {
const triples = gradesWithAtLeast(byGrade, 3);
const pairs = gradesWithAtLeast(byGrade, 2);
let best = null;
triples.forEach((tg) => {
pairs.filter((pg) => pg !== tg).forEach((pg) => {
const picked = [...byGrade.get(tg).slice(0, 3), ...byGrade.get(pg).slice(0, 2)];
if (!best || sumGrades(picked) > sumGrades(best)) best = picked;
});
});
return best;
}
case 'flush_axis': {
const byAxis = groupByAxis(selection);
let best = null;
byAxis.forEach((cards) => {
if (cards.length < 5) return;
const picked = [...cards].sort((a, b) => b.grade - a.grade).slice(0, 5);
if (!best || sumGrades(picked) > sumGrades(best)) best = picked;
});
return best;
}
case 'straight':
return findRun(selection, 5);
case 'straight_flush': {
const byAxis = groupByAxis(selection);
let best = null;
byAxis.forEach((cards) => {
const run = findRun(cards, 5);
if (run && (!best || sumGrades(run) > sumGrades(best))) best = run;
});
return best;
}
default:
return null;
}
}
/**
* 선택 집합으로 성립하는 족보(들)를 판정한다.
* 여러 족보가 동시 성립할 수 있다 (§5.3) — 예: 같은 축 연속 5장은 straight_flush / flush_axis /
* straight 셋 다 성립하며, 플레이어가 하나를 고른다(명분 종류를 고르려고 일부러 하위를 쓰는 선택).
* @returns {string[]} 성립하는 HAND_DEFS id 배열, 상위 족보 우선 정렬
*/
export function detectHands(selection) {
const n = selection.length;
const matches = new Set();
if (n === 2 && sameGrade(selection)) matches.add('pair');
if (n === 3) {
if (sameGrade(selection)) matches.add('triple');
if (sameAxis(selection)) matches.add('flush_axis');
if (isAscendingRun(selection)) matches.add('straight');
if (sameAxis(selection) && isAscendingRun(selection)) matches.add('straight_flush');
}
if (n === 4) {
if (sameGrade(selection)) matches.add('four_kind');
if (sameAxis(selection) && isAscendingRun(selection)) matches.add('straight_flush');
}
if (n === 5) {
const counts = gradeCounts(selection);
if (counts[0] === 3 && counts[1] === 2) matches.add('full_house');
}
return [...matches].sort((a, b) => HAND_RANK.indexOf(b) - HAND_RANK.indexOf(a));
if (selection.length < MIN_COMBINE_CARDS || selection.length > MAX_COMBINE_CARDS) return [];
return HAND_RANK
.filter((id) => findHandCards(selection, id) !== null)
.sort((a, b) => HAND_RANK.indexOf(b) - HAND_RANK.indexOf(a));
}
/**
* 선택한 족보로 합본을 실행한 결과를 계산한다 (책상에서 실제로 지우는 건 호출부 책임).
* @param {Array<{id,axis,grade}>} selection
* @param {string} patternId - HAND_DEFS의 key
* removeIds에는 족보를 이룬 카드만 담긴다 — 선택했지만 족보에 쓰이지 않은 카드는 책상에 남는다.
*/
export function resolveCombine(selection, patternId) {
const def = HAND_DEFS[patternId];
if (!def) return null;
const [minSize, maxSize] = def.size;
if (selection.length < minSize || selection.length > maxSize) return null;
const used = findHandCards(selection, patternId);
if (!used) return null;
const uniformAxis = sameAxis(selection) ? selection[0].axis : null;
const uniformAxis = used.every((c) => c.axis === used[0].axis) ? used[0].axis : null;
const justificationType = uniformAxis ? justificationOf(uniformAxis) : '혼합';
const hasTopSecret = selection.some((c) => c.grade === 4);
const hasTopSecret = used.some((c) => c.grade >= TOP_SECRET_THRESHOLD);
const justificationAmount = def.baseJustification + (hasTopSecret ? 1 : 0);
// 토큰 산출 (§8.2): 축이 하나로 통일된 합본만 그 축의 전용 토큰을 낸다.
// 위협 축(토큰 미지정) 또는 축이 섞인 합본은 "소량 전종" 규칙(§8.2 위협 축 대안)을 공통 적용한다.
// 정확한 배분량은 미확정 수치이며 플레이 테스트로 튜닝할 가안이다.
const axisToken = uniformAxis ? tokenOf(uniformAxis) : null;
const tokens = axisToken
? { [axisToken]: selection.length }
: { budget: 1, personnel: 1, info: 1 };
// §8.2: 토큰은 1종, 산출량은 합본에 사용된 카드의 기밀 등급 합계.
const tokens = sumGrades(used);
return {
pattern: def,
usedCards: used,
justification: { type: justificationType, amount: justificationAmount },
tokens,
performanceScore: def.score,
removeIds: selection.map((c) => c.id),
removeIds: used.map((c) => c.id),
};
}
+16 -6
View File
@@ -10,13 +10,23 @@ const POOL = Papa.parse(poolCsvRaw, { header: true, skipEmptyLines: true }).data
/**
* 주어진 축·등급에 맞는 오염 카드 항목을 하나 뽑는다.
* 정확히 맞는 등급이 없으면 같은 축 안에서 아무거나 폴백한다 (템플릿 물량이 등급별로
* 고르지 않을 수 있어서다 — pollution_pool.csv는 등급당 딱 1~2개뿐이다).
* 등급이 1~10으로 확장된 뒤 pollution_pool.csv는 축당 몇 개 등급대만 갖고 있으므로,
* 정확히 일치하는 등급이 없으면 **가장 가까운 등급**의 플레이버를 쓴다. 아무거나 폴백하면
* 등급 10 사안에 "초과근무 항의" 같은 저강도 서술이 붙어 심각도와 텍스트가 어긋난다.
* (등급대별 신규 오염 카드 텍스트는 CLAUDE.md "데이터 파일 다루는 법"에 따라 소량 배치로 채운다.)
*/
export function pickPollutionEntry(axis, grade) {
const exact = POOL.filter((r) => r.axis === axis && Number(r.grade) === Number(grade));
const pool = exact.length > 0 ? exact : POOL.filter((r) => r.axis === axis);
if (pool.length === 0) return null;
const row = pool[Math.floor(Math.random() * pool.length)];
const sameAxis = POOL.filter((r) => r.axis === axis);
if (sameAxis.length === 0) return null;
const target = Number(grade);
let bestDistance = Infinity;
sameAxis.forEach((r) => {
const d = Math.abs(Number(r.grade) - target);
if (d < bestDistance) bestDistance = d;
});
const nearest = sameAxis.filter((r) => Math.abs(Number(r.grade) - target) === bestDistance);
const row = nearest[Math.floor(Math.random() * nearest.length)];
return { name: row.name, flavor: row.flavor, tag: row.tag || null };
}
+11 -14
View File
@@ -3,19 +3,24 @@
// 그래서 2차에서 성립하는 품목은 "오염 카드 직접 처리"(§8.3 표: 족보가 안 맞을 때의 탈출구,
// 명분·실적 없음) 하나뿐이다 — 이 자체가 §17 로드맵 순서를 지키기 위한 의도적 축소다.
// 진열 규칙(무작위 4~6개, 1회 리롤 가능·토큰 소모)만 그대로 §8.3을 따른다.
//
// 2026-09-15 결정 반영: 토큰 1종 통합(§8.2, §19). 가격은 단일 숫자다.
// 등급 1~10 확장에 맞춰 처리 비용도 등급에 비례한다 — 합본하면 등급 합계만큼 토큰을 벌지만
// 직접 처리하면 그만큼 잃으므로, 탈출구를 쓰는 대가가 등급에 정직하게 비례한다.
export const SHOP_DISPLAY_MIN = 4;
export const SHOP_DISPLAY_MAX = 6;
export const REROLL_COST = { budget: 3 };
export const REROLL_COST = 3;
/** 오염 카드 1장을 직접 처리(소각)하는 가격 — 등급이 높을수록, 책상이 꽉 찰수록 비싸진다. */
export function purgePrice(card, currentSurcharge) {
return { budget: card.grade * 3 + currentSurcharge };
/** 오염 카드 1장을 직접 처리(소각)하는 가격 — 등급만큼, 책상이 무거우면(지출 가산) 더 비싸다. */
export function purgePrice(card, currentSurcharge = 0) {
return card.grade + currentSurcharge;
}
/** 책상 카드 중 무작위로 4~6장(또는 그 미만이면 전부)을 진열한다. */
export function rollShopDisplay(deskCards) {
const n = Math.min(deskCards.length, SHOP_DISPLAY_MIN + Math.floor(Math.random() * (SHOP_DISPLAY_MAX - SHOP_DISPLAY_MIN + 1)));
const span = SHOP_DISPLAY_MAX - SHOP_DISPLAY_MIN + 1;
const n = Math.min(deskCards.length, SHOP_DISPLAY_MIN + Math.floor(Math.random() * span));
const pool = [...deskCards];
const out = [];
while (out.length < n && pool.length > 0) {
@@ -24,12 +29,4 @@ export function rollShopDisplay(deskCards) {
return out;
}
export function canAfford(tokens, price) {
return Object.entries(price).every(([k, v]) => (tokens[k] || 0) >= v);
}
export function payTokens(tokens, price) {
const next = { ...tokens };
Object.entries(price).forEach(([k, v]) => { next[k] = (next[k] || 0) - v; });
return next;
}
export const canAfford = (tokens, price) => tokens >= price;
+25 -17
View File
@@ -1,11 +1,16 @@
// 기호 체계 (ProjectSS_코어시스템_기획서.md §3) — 모든 카드가 공유하는 단일 기호 언어.
// 스와이프 판단은 텍스트가 아니라 이 기호(축 인장 + 기밀 등급)만으로 0.5초 내에 이루어져야 한다.
// 색상만으로 구분하지 말 것 — 아이콘 형태(실루엣) 자체가 축을 구분해야 한다 (§3.1).
//
// 2026-09-15 결정 반영: 기밀 등급 1~4 → 1~10 확장, 점(●) 표기 폐기.
// 카드 중앙에 큰 숫자, 축 인장은 모서리에 작게 — 트럼프 랭크+슈트 레이아웃 (§3.2).
// 토큰은 1종으로 통합됐으므로(§8.2, §19) 축은 더 이상 토큰 종류를 결정하지 않는다.
// 축이 결정하는 것은 "명분 종류"(질)뿐이고, 토큰 양(양)은 등급 합계가 결정한다 (§7.2).
export const AXES = ['axis_threat', 'axis_hr', 'axis_pr', 'axis_external'];
// 인장(아이콘)은 lucide-react의 Tornado/Hand/Megaphone/Stamp를 그대로 쓴다.
// 실제 아이콘 컴포넌트 매핑은 AxisIcon.jsx에서 담당 — 여기는 축의 이름·게이지·명분·토큰 매핑만.
// 실제 아이콘 컴포넌트 매핑은 SymbolBadge.jsx에서 담당 — 여기는 축의 이름·게이지·명분 매핑만.
export const AXIS_META = {
axis_threat: {
name: '위협',
@@ -13,7 +18,6 @@ export const AXIS_META = {
gauge: 'entropy',
gaugeName: '엔트로피',
justification: '위기',
token: null, // §8.2: 위협 축은 플레이어 선택(또는 소량 전종) — economy.js에서 처리
},
axis_hr: {
name: '인사',
@@ -21,7 +25,6 @@ export const AXIS_META = {
gauge: 'resistance',
gaugeName: '조직 저항',
justification: '내부',
token: 'personnel',
},
axis_pr: {
name: '공보',
@@ -29,7 +32,6 @@ export const AXIS_META = {
gauge: 'panic',
gaugeName: '대중 공황',
justification: '여론',
token: 'info',
},
axis_external: {
name: '대외',
@@ -37,26 +39,32 @@ export const AXIS_META = {
gauge: 'risk',
gaugeName: '위험 수용 계수',
justification: '절차',
token: 'budget',
},
};
export const GRADE_MIN = 1;
export const GRADE_MAX = 4;
export const GRADE_MAX = 10;
export const GRADE_NAME = {
1: '일반',
2: '대외비',
3: '2급 기밀',
4: '1급 기밀',
// 등급 범위 명칭 (§3.2). 서사·문체용 라벨일 뿐이며, 합본 판정·주목도 계산 등
// 모든 게임 로직은 항상 원 숫자(1~10)를 쓴다.
const GRADE_BANDS = [
{ max: 2, name: '일반' },
{ max: 4, name: '대외비' },
{ max: 6, name: '2급 기밀' },
{ max: 8, name: '1급 기밀' },
{ max: 10, name: '특급 기밀' },
];
export const gradeBandName = (grade) => {
const g = clampGrade(grade);
return GRADE_BANDS.find((b) => g <= b.max)?.name ?? '일반';
};
// 숫자가 아닌 점 개수로 표기한다 (§3.2) — "●●●○" 형태 문자열을 만들어 준다.
export const gradeDots = (grade) => {
const g = Math.max(GRADE_MIN, Math.min(GRADE_MAX, grade || 0));
return '●'.repeat(g) + '○'.repeat(GRADE_MAX - g);
};
export const clampGrade = (grade) =>
Math.max(GRADE_MIN, Math.min(GRADE_MAX, Math.round(grade || GRADE_MIN)));
// 명분 +1 보너스 기준 등급 (§7.1: "등급 8 이상(1급 기밀 이상) 카드가 포함되면 +1")
export const TOP_SECRET_THRESHOLD = 8;
export const gaugeOf = (axis) => AXIS_META[axis]?.gauge ?? null;
export const justificationOf = (axis) => AXIS_META[axis]?.justification ?? '혼합';
export const tokenOf = (axis) => AXIS_META[axis]?.token ?? null;