구현 우선 순위 1차 진행 완료
- 1차 — 코어 검증 (여기까지만 되어도 재미 판정 가능) 1. **기호 체계 + 책상 상태 상시 노출 UI** (§3) 2. **책상 적재 + 누진 패시브 페널티 A·B** (§4) 3. **표준 족보 판정 및 합본 실행** (§5) 4. **합본 기반 토큰 산출** (§8.2) 게임 플레이 후 피드백 해결 - 내용은 아래 문서 참고 - docs/decisions/2026-09-05_core-graybox-ux-feedback.md
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
// 책상(Desk) 엔진 — ProjectSS_코어시스템_기획서.md §4.
|
||||
// 오염 카드는 덱에 들어가지 않는다. 드로우·셔플 없음, 슬롯 무제한. 여기서 다루는 desk는
|
||||
// 순수 배열 [{ id, axis, grade }] 이며, React state는 이 배열을 그대로 들고 있다가
|
||||
// 이 모듈의 함수로 파생값(축별 스택, 패널티)을 계산한다. React/훅 의존 없음 — 순수 함수만.
|
||||
|
||||
import { AXES } 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,
|
||||
name: meta.name || null,
|
||||
flavor: meta.flavor || null,
|
||||
tag: meta.tag || null,
|
||||
});
|
||||
|
||||
/** 축별 적재 장수. { axis_threat: 3, axis_hr: 1, ... } 형태로 항상 4축 전부를 채워 반환한다. */
|
||||
export function countByAxis(deskCards) {
|
||||
const out = Object.fromEntries(AXES.map((a) => [a, 0]));
|
||||
deskCards.forEach((c) => { out[c.axis] = (out[c.axis] || 0) + 1; });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 축별 등급 분포. { axis_threat: {1: 2, 3: 1}, ... } — DeskBar의 축×등급 매트릭스 표시용. */
|
||||
export function gradeBreakdownByAxis(deskCards) {
|
||||
const out = Object.fromEntries(AXES.map((a) => [a, {}]));
|
||||
deskCards.forEach((c) => {
|
||||
out[c.axis][c.grade] = (out[c.axis][c.grade] || 0) + 1;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
/** 해당 축의 오염 압력(P) = Σ 카드별 등급 가중치. */
|
||||
export function axisPressure(deskCards, axis) {
|
||||
return deskCards
|
||||
.filter((c) => c.axis === axis)
|
||||
.reduce((sum, c) => sum + (GRADE_WEIGHT[c.grade] || 0), 0);
|
||||
}
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
/** 오염 압력(P)에 따른 결재 1건당 게이지 상승률(%). */
|
||||
export function gaugeRiseRate(pressure) {
|
||||
const tier = GAUGE_TIERS.find((t) => pressure <= t.max);
|
||||
return tier ? tier.rate : 13.0;
|
||||
}
|
||||
|
||||
/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다. */
|
||||
export function gaugeRiseRateByAxis(deskCards) {
|
||||
const out = {};
|
||||
AXES.forEach((axis) => { out[axis] = gaugeRiseRate(axisPressure(deskCards, axis)); });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */
|
||||
export function applyPassiveGaugeRise(gauges, deskCards) {
|
||||
const next = { ...gauges };
|
||||
AXES.forEach((axis) => {
|
||||
const gaugeKey = axisGaugeKey(axis);
|
||||
const rate = gaugeRiseRate(axisPressure(deskCards, axis));
|
||||
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) — 총 적재 장수에 따라 모든 토큰 지출에 가산 ── */
|
||||
const SURCHARGE_TIERS = [
|
||||
{ max: 7, surcharge: 0 },
|
||||
{ max: 14, surcharge: 1 },
|
||||
{ max: 21, surcharge: 2 },
|
||||
{ max: Infinity, surcharge: 4 },
|
||||
];
|
||||
|
||||
/** 총 적재 장수 기준 모든 토큰 지출에 붙는 가산액. */
|
||||
export function tokenSurcharge(deskCards) {
|
||||
const total = totalCount(deskCards);
|
||||
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
|
||||
return tier ? tier.surcharge : 4;
|
||||
}
|
||||
|
||||
/** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */
|
||||
export function removeFromDesk(deskCards, idsToRemove) {
|
||||
const idSet = new Set(idsToRemove);
|
||||
return deskCards.filter((c) => !idSet.has(c.id));
|
||||
}
|
||||
|
||||
export function addToDesk(deskCards, newCards) {
|
||||
return [...deskCards, ...newCards];
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
// 합본(족보) 판정 · 실행 엔진 — 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에서 그대로 가져와 표 값 위에 얹었다.
|
||||
|
||||
import { justificationOf, tokenOf } from './symbols.js';
|
||||
|
||||
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 },
|
||||
};
|
||||
|
||||
// 족보 우선순위(높을수록 상위) — 동시 성립 시 기본 선택값과 정렬 순서에 쓴다.
|
||||
const HAND_RANK = ['pair', 'triple', 'flush_axis', 'straight', '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);
|
||||
|
||||
/** 등급이 중복 없이 연속 오름차순인가 (예: 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 gradeCounts(cards) {
|
||||
const m = {};
|
||||
cards.forEach((c) => { m[c.grade] = (m[c.grade] || 0) + 1; });
|
||||
return Object.values(m).sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 책상 카드 묶음이 어떤 족보(들)로 성립하는지 판정한다.
|
||||
* 여러 족보가 동시 성립할 수 있다 (§5.3) — 예: 같은 축 + 연속 등급 3장은
|
||||
* flush_axis / straight / straight_flush 세 가지 모두로 성립하며, 플레이어가 하나를 고른다.
|
||||
* @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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 족보로 합본을 실행한 결과를 계산한다 (책상에서 실제로 지우는 건 호출부 책임).
|
||||
* @param {Array<{id,axis,grade}>} selection
|
||||
* @param {string} patternId - HAND_DEFS의 key
|
||||
*/
|
||||
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 uniformAxis = sameAxis(selection) ? selection[0].axis : null;
|
||||
const justificationType = uniformAxis ? justificationOf(uniformAxis) : '혼합';
|
||||
const hasTopSecret = selection.some((c) => c.grade === 4);
|
||||
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 };
|
||||
|
||||
return {
|
||||
pattern: def,
|
||||
justification: { type: justificationType, amount: justificationAmount },
|
||||
tokens,
|
||||
performanceScore: def.score,
|
||||
removeIds: selection.map((c) => c.id),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
// 오염 카드 풀 — data/pollution_pool.csv를 그대로 읽어 "이 축·등급이면 어떤 이름과
|
||||
// 사연을 가진 오염 카드인가"를 뽑아 준다. 지금까지는 이 CSV가 어디서도 쓰이지 않아서
|
||||
// 책상 카드가 축 라벨만 가진 익명 토큰이었다 — 플레이테스트 피드백(선택의 서사가 안 보임)의
|
||||
// 원인 중 하나였다. React 의존 없는 순수 조회 함수만 둔다.
|
||||
|
||||
import Papa from 'papaparse';
|
||||
import poolCsvRaw from '../../data/pollution_pool.csv?raw';
|
||||
|
||||
const POOL = Papa.parse(poolCsvRaw, { header: true, skipEmptyLines: true }).data;
|
||||
|
||||
/**
|
||||
* 주어진 축·등급에 맞는 오염 카드 항목을 하나 뽑는다.
|
||||
* 정확히 맞는 등급이 없으면 같은 축 안에서 아무거나 폴백한다 (템플릿 물량이 등급별로
|
||||
* 고르지 않을 수 있어서다 — pollution_pool.csv는 등급당 딱 1~2개뿐이다).
|
||||
*/
|
||||
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)];
|
||||
return { name: row.name, flavor: row.flavor, tag: row.tag || null };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// 기호 체계 (ProjectSS_코어시스템_기획서.md §3) — 모든 카드가 공유하는 단일 기호 언어.
|
||||
// 스와이프 판단은 텍스트가 아니라 이 기호(축 인장 + 기밀 등급)만으로 0.5초 내에 이루어져야 한다.
|
||||
// 색상만으로 구분하지 말 것 — 아이콘 형태(실루엣) 자체가 축을 구분해야 한다 (§3.1).
|
||||
|
||||
export const AXES = ['axis_threat', 'axis_hr', 'axis_pr', 'axis_external'];
|
||||
|
||||
// 인장(아이콘)은 lucide-react의 Tornado/Hand/Megaphone/Stamp를 그대로 쓴다.
|
||||
// 실제 아이콘 컴포넌트 매핑은 AxisIcon.jsx에서 담당 — 여기는 축의 이름·게이지·명분·토큰 매핑만.
|
||||
export const AXIS_META = {
|
||||
axis_threat: {
|
||||
name: '위협',
|
||||
sealName: '소용돌이',
|
||||
gauge: 'entropy',
|
||||
gaugeName: '엔트로피',
|
||||
justification: '위기',
|
||||
token: null, // §8.2: 위협 축은 플레이어 선택(또는 소량 전종) — economy.js에서 처리
|
||||
},
|
||||
axis_hr: {
|
||||
name: '인사',
|
||||
sealName: '손',
|
||||
gauge: 'resistance',
|
||||
gaugeName: '조직 저항',
|
||||
justification: '내부',
|
||||
token: 'personnel',
|
||||
},
|
||||
axis_pr: {
|
||||
name: '공보',
|
||||
sealName: '확성기',
|
||||
gauge: 'panic',
|
||||
gaugeName: '대중 공황',
|
||||
justification: '여론',
|
||||
token: 'info',
|
||||
},
|
||||
axis_external: {
|
||||
name: '대외',
|
||||
sealName: '도장',
|
||||
gauge: 'risk',
|
||||
gaugeName: '위험 수용 계수',
|
||||
justification: '절차',
|
||||
token: 'budget',
|
||||
},
|
||||
};
|
||||
|
||||
export const GRADE_MIN = 1;
|
||||
export const GRADE_MAX = 4;
|
||||
|
||||
export const GRADE_NAME = {
|
||||
1: '일반',
|
||||
2: '대외비',
|
||||
3: '2급 기밀',
|
||||
4: '1급 기밀',
|
||||
};
|
||||
|
||||
// 숫자가 아닌 점 개수로 표기한다 (§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 gaugeOf = (axis) => AXIS_META[axis]?.gauge ?? null;
|
||||
export const justificationOf = (axis) => AXIS_META[axis]?.justification ?? '혼합';
|
||||
export const tokenOf = (axis) => AXIS_META[axis]?.token ?? null;
|
||||
Reference in New Issue
Block a user