Files
ProjectSS/client/src/core/engine/desk.js
T
kyoung5seoandClaude Sonnet 5 20207ff23c 라운드 접수량 버그 수정 + 토큰→코인 용어 통일 + 외부 배포(NAS)
플레이 피드백 4건(주목도 미차감, 상점 빈 상품, 상점 UX 오해, 재화 명칭)을
검토해 방향이 확정된 항목만 반영하고, 외부 테스터에게 링크를 공유할 수 있게
시놀로지 NAS 배포까지 준비했다.

## 게임 로직

- 라운드 접수량 크리티컬 버그 수정: 합본을 반복하면 라운드가 안 끝나던 문제.
  deskCards에서 파생시키던 값을 순수 누적 카운터(roundIntake)로 교체 — 결정
  노트: 2026-09-18-라운드-접수량-누적카운터
- DeskBar/CoreSwipeCard 정보 역할 분리: "이번 라운드 접수량/한도"는 결재
  화면 전담, DeskBar는 책상에 쌓인 것(총 주목도·축별 분포)만 표시
- 토큰 → 코인 용어 일괄 변경: core/ 전체(식별자·CSS·주석·UI)와 기획서 SSOT
  동기화, lucide Coins 아이콘 추가

## 배포

- vite base를 상대 경로로 변경 (Web Station 서브패스 서빙 시 asset 404 방지)
- 모바일 100vh → 100dvh (주소창 노출 시 하단 버튼 가림 방지)
- 빌드 식별자 표시, 타이틀/테마색/description 정리
- deploy.sh + npm run deploy — SSH 키 권한 문제(777 → StrictModes 거부)와
  NAS rsync 게이트를 scp -O로 우회해 실제 배포까지 검증 완료
  → https://play.mint-grape.duckdns.org/projectss/

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 23:20:22 +09:00

186 lines
9.5 KiB
JavaScript

// 책상(Desk) 엔진 — ProjectSS_코어시스템_기획서.md §4.
// 오염 카드는 덱에 들어가지 않는다. 드로우·셔플 개념 없음, 슬롯 무제한. 여기서 다루는 desk는
// 순수 배열 [{ id, axis, grade, name, flavor, tag }]이며, React state는 이 배열을 그대로 들고
// 있다가 이 모듈의 함수로 파생값(주목도, 페널티)을 계산한다. React/훅 의존 없음 — 순수 함수만.
//
// 2026-09-15 결정 반영: 페널티 기준을 "카드 장수" → "등급 합계"로 전환하고 이를 주목도라 부른다.
// 등급 편차가 1~10으로 커진 만큼, 장수만 세면 특급 기밀과 일반 문서가 똑같이 취급돼 압박이
// 정직하지 않다. 등급 자체가 곧 가중치이므로 별도 가중치 테이블은 없다.
//
// 이월(§4.1): 합본에 사용되지 않은 카드는 소멸하지 않고 책상에 남는다. 별도 이월 시스템이
// 없는 이유가 바로 이것 — "합본이 닿은 카드만 사라진다"는 규칙 하나로 자연히 성립한다.
import { AXES, AXIS_META, clampGrade } from './symbols.js';
let seq = 0;
// meta: pollutionPool.js에서 뽑은 { name, flavor, tag } — 책상 카드가 실제 이름/사연을 갖게 한다.
// round: 이 카드를 받은 라운드 번호(기록용 — 한도 판정에는 더 이상 쓰이지 않는다. 아래 참고).
export const makeDeskCard = (axis, grade, meta = {}, round = 1) => ({
id: `desk-${Date.now().toString(36)}-${(seq++).toString(36)}`,
axis,
grade: clampGrade(grade),
round,
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: {7: 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;
/* ── 주목도 (§4.2) — 페널티 산정의 단일 기준 ── */
/** 해당 축의 주목도 = 그 축 카드들의 등급 합. */
export function axisAttention(deskCards, axis) {
return deskCards
.filter((c) => c.axis === axis)
.reduce((sum, c) => sum + c.grade, 0);
}
/** 책상 전체 주목도 = 모든 카드의 등급 합. 이월 카드까지 포함하며, 패시브 페널티
* (게이지 상승 A · 코인 가산 C)의 기준이다 — 쌓아둔 미결은 계속 대가를 치른다. */
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) — 축별 카드 "장수" 기준 급누진 + 등급 배율.
*
* 2026-09-17 재설계: 등급 합계(attention)만으로는 상승이 너무 느렸다 — 이 게임 카드의 등급이
* 대부분 1~4에 몰려 있어서(§17 카드 데이터 현황), 같은 축 카드 서너 장을 쌓아도 attention이
* 낮은 구간에 계속 머물러 위협이 전혀 체감되지 않았다(플레이 피드백). 그래서 1차 기준을
* "쌓인 장수"로 되돌리되, 등급이 여전히 의미를 갖도록 평균 등급 배율을 곱한다 —
* 저등급 카드만 쌓이면 완만하고, 고등급이 섞이면 같은 장수에서도 훨씬 아프다.
* 피드백 원문: "누적 카드 3장 이상일 때부터 증가값이 엄청 커졌으면 함". */
const GAUGE_COUNT_TIERS = [
{ max: 2, base: 0.5 }, // 1~2장: 아직 완만
{ max: 4, base: 3.0 }, // 3~4장: 여기서 확 뛴다 (피드백 핵심 요구)
{ max: 6, base: 6.0 },
{ max: 9, base: 10.0 },
];
const GAUGE_COUNT_OVERRUN_FROM = 10;
const GAUGE_COUNT_OVERRUN_BASE = 15.0;
const GAUGE_COUNT_OVERRUN_STEP = 2.0; // 10장부터 1장마다 가산
const GAUGE_GRADE_BASELINE = 5; // 이 등급이 "평균" — 배율 1.0 기준점 (등급 범위 1~10의 중앙)
/** 축에 쌓인 장수 기준 결재 1건당 게이지 상승 기본값(%). */
function gaugeCountBase(count) {
const tier = GAUGE_COUNT_TIERS.find((t) => count <= t.max);
if (tier) return tier.base;
return GAUGE_COUNT_OVERRUN_BASE + (count - GAUGE_COUNT_OVERRUN_FROM) * GAUGE_COUNT_OVERRUN_STEP;
}
/** 축 카드 수·등급 합계로 결재 1건당 게이지 상승률(%)을 낸다.
* base(장수 구간) × (평균 등급 / 5) — 고등급이 섞이면 같은 장수에서도 더 아프다. */
export function gaugeRiseRate(count, attention) {
if (count <= 0) return 0;
const base = gaugeCountBase(count);
const avgGrade = attention / count;
return base * (avgGrade / GAUGE_GRADE_BASELINE);
}
/** 축별 현재 상승률(%) — DeskBar의 "▲+X%/결재" 표시에 그대로 쓴다.
* extraFlat: 활성 징벌 규약(§9.2 유형C)이 얹는 가산분(council.js의 penaltyGaugeSurcharge). */
export function gaugeRiseRateByAxis(deskCards, extraFlat = 0) {
const counts = countByAxis(deskCards);
const out = {};
// extraFlat(징벌 규약 §9.2 유형C)은 "모든 축의 게이지 상승"이라 카드 유무와 무관하게 항상 더한다.
AXES.forEach((axis) => {
out[axis] = gaugeRiseRate(counts[axis], axisAttention(deskCards, axis)) + extraFlat;
});
return out;
}
/** 매 결재마다 4개 게이지 전부에 축별 상승분을 더한다. gauges = { entropy, resistance, panic, risk } (0~100). */
export function applyPassiveGaugeRise(gauges, deskCards, extraFlat = 0) {
const counts = countByAxis(deskCards);
const next = { ...gauges };
AXES.forEach((axis) => {
const gaugeKey = AXIS_META[axis].gauge;
const rate = gaugeRiseRate(counts[axis], axisAttention(deskCards, axis)) + extraFlat;
next[gaugeKey] = Math.min(100, (next[gaugeKey] || 0) + rate);
});
return next;
}
/* ── B. 라운드 종료 판단 (§4.2-B, 2026-09-16 결정 재수정) — 블랙잭식 하드 캡.
* 라운드당 주목도 한도를 넘기면 그 즉시 라운드가 강제 종료된다(합본 기회 없이, 카드는 이월).
* 한도 자체가 라운드가 갈수록 올라가 후반에는 더 크게 쌓아도 되는 대신 압박도 커진다.
* 한도 밑에서는 플레이어가 아무 때나 자율적으로 라운드를 끝내고 정산으로 넘어갈 수 있다 —
* "한 장 더 받을까 말까"의 긴장은 여기서 나온다. 결정 노트: 2026-09-16-라운드-주목도-하드캡-복원.
*
* 2026-09-18 결정 (누적 카운터로 전환, 결정 노트: 2026-09-18-라운드-접수량-누적카운터):
* 이번 라운드 접수량을 "책상에서 이번 라운드 카드를 필터링해 합산"하는 파생값으로 뒀더니,
* 합본으로 그 카드들이 책상에서 사라지는 순간 접수량도 같이 줄었다. 블랙잭은 낸 카드를
* 버려서 핸드 합계를 낮출 수 없는데, 이 파생 방식은 그걸 가능하게 해서 "합본을 반복하면
* 라운드가 끝나지 않는" 문제를 낳았다(피드백: "합본을 많이 하면 라운드가 너무 길어진다").
* 그래서 접수량은 더 이상 desk.js가 deskCards에서 계산하지 않는다 — CoreGameScreen이
* 카드를 받을 때마다 등급만큼 더하는 **순수 누적 카운터**(라운드가 바뀔 때만 0으로 리셋)로
* 관리하고, 이 모듈의 함수들은 그 값을 인자로 받기만 한다. */
const ROUND_CAP_BASE = 21; // 가안 — 최초 블랙잭 기준으로 복귀
const ROUND_CAP_GROWTH = 5; // 라운드당 가산분, 가안
/** 라운드 번호에 따른 이번 라운드의 주목도 한도. bonus: 이득 규약의 한도 보너스(§9.2 유형 D). */
export function roundAttentionCap(round, bonus = 0) {
return ROUND_CAP_BASE + (round - 1) * ROUND_CAP_GROWTH + bonus;
}
/** 한도 대비 이번 라운드 접수량 구간: 'normal' | 'warning'(한도 5 이내로 근접) | 'bust'(한도 초과).
* intake: CoreGameScreen이 들고 있는 누적 카운터 값 — 합본해도 줄지 않는다. */
export function attentionZone(intake, cap) {
if (intake > cap) return 'bust';
if (intake >= cap - 5) return 'warning';
return 'normal';
}
/* ── C. 코인 지출 가산 (§4.2-C) — 총 주목도 기준 ── */
const SURCHARGE_TIERS = [
{ max: 20, surcharge: 0 },
{ max: 40, surcharge: 1 },
{ max: 60, surcharge: 2 },
{ max: Infinity, surcharge: 4 },
];
/** 총 주목도 기준 모든 코인 지출에 붙는 가산액.
* extraFlat: 활성 규약이 얹는 가산분(council.js의 coinSurchargeOf) — 이득 규약이면 음수라
* 0 밑으로 내려갈 수 있으므로, 지출이 되레 할인되지 않도록 0에서 끊는다. */
export function coinSurcharge(deskCards, extraFlat = 0) {
const total = totalAttention(deskCards);
const tier = SURCHARGE_TIERS.find((t) => total <= t.max);
return Math.max(0, (tier ? tier.surcharge : 4) + extraFlat);
}
/** 책상에서 특정 카드들을 제거한 새 배열을 반환한다 (합본 실행 시 사용). */
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];
}