소프트락 감사: 재추첨 장터 해금 + 진행 불가 위험 3건 수정

- 입양 재추첨 버튼: 장터 완공 전까지 숨김 (완공 후 해금)
- 부엌 있는데 요리 가능 고양이가 없으면 입양 후보에 요리 적성 보장
- 서버 접속 불가 시에도 닉네임 결정 가능 (오프라인 시작 → 이후 자동 재등록)
- 저장 403(미등록 닉네임) 시 재등록 후 재시도
- 파업 중 굶주림 행복도 하락 제외 + 자연 회복 +2/분 (전원 파업 데드락 방지)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-14 11:25:59 +09:00
co-authored by Claude Fable 5
부모 ad6dbdbe97
커밋 1b438d392a
4개의 변경된 파일과 60개의 추가작업 그리고 25개의 파일을 삭제
+33 -15
파일 보기
@@ -315,7 +315,10 @@ export default function Game() {
🐾 고양이
</button>
<button
onClick={() =>
onClick={() => {
const needCook =
state.buildings.some((b) => b.type === "kitchen") &&
!state.cats.some((c) => c.aptitude.cooking > 0);
setCandidates(
engine.generateCandidates(
3,
@@ -323,10 +326,12 @@ export default function Game() {
? "fishing"
: state.tutorialStep === 4
? "logging"
: undefined,
: needCook
? "cooking"
: undefined,
),
)
}
);
}}
disabled={state.cats.length >= state.hall.capacity}
className={"nature-btn flex items-center gap-1.5 px-4 py-2 text-[15px] disabled:cursor-not-allowed disabled:opacity-50" + tut("adopt")}
>
@@ -523,9 +528,15 @@ export default function Game() {
highlightPick={tutTarget === "adopt-pick"}
cost={adoptCost(state)}
coins={Math.floor(state.coins ?? 0)}
rerollUnlocked={state.buildings.some(
(b) => b.type === "market" && b.constructionUntil === undefined,
)}
onReroll={() => {
const err = run(() => engine.payReroll());
if (!err)
if (!err) {
const needCook =
state.buildings.some((b) => b.type === "kitchen") &&
!state.cats.some((c) => c.aptitude.cooking > 0);
setCandidates(
engine.generateCandidates(
3,
@@ -533,9 +544,12 @@ export default function Game() {
? "fishing"
: state.tutorialStep === 4
? "logging"
: undefined,
: needCook
? "cooking"
: undefined,
),
);
}
}}
onPick={(cat) => {
const err = run(() => engine.adoptCandidate(cat));
@@ -1333,6 +1347,7 @@ function AdoptModal({
cost,
onReroll,
coins,
rerollUnlocked,
}: {
candidates: Cat[];
onPick: (cat: Cat) => void;
@@ -1341,6 +1356,7 @@ function AdoptModal({
cost: number;
onReroll: () => void;
coins: number;
rerollUnlocked?: boolean;
}) {
return (
<div
@@ -1431,15 +1447,17 @@ function AdoptModal({
);
})}
</div>
<div className="mt-4 flex items-center justify-center gap-3">
<button
onClick={onReroll}
disabled={coins < REROLL_COST}
className="wood-plank px-4 py-1.5 text-xs hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
>
🎲 다른 아이들 보기 (🪙{REROLL_COST})
</button>
</div>
{rerollUnlocked && (
<div className="mt-4 flex items-center justify-center gap-3">
<button
onClick={onReroll}
disabled={coins < REROLL_COST}
className="wood-plank px-4 py-1.5 text-xs hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-40"
>
🎲 다른 아이들 보기 (🪙{REROLL_COST})
</button>
</div>
)}
<p className="mt-3 text-center text-xs text-[#8a7551]">
한 마리만 선택할 수 있어요 · 패시브에 마우스를 올리면 상세 정보가 보입니다
</p>
+5 -3
파일 보기
@@ -56,10 +56,12 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
};
}, [trimmed, mode]);
// 모드별 결정 가능 조건: 새 마을=사용 가능, 불러오기=이미 존재
// 모드별 결정 가능 조건: 새 마을=사용 가능(서버 불가 시에도 허용), 불러오기=이미 존재
const canConfirm =
!submitting &&
(mode === "new" ? status === "available" : status === "taken");
(mode === "new"
? status === "available" || status === "offline"
: status === "taken");
const confirm = async () => {
if (!canConfirm) return;
@@ -96,7 +98,7 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
checking: { text: "다른 마을과 겹치는지 확인 중… 🐾", cls: "text-[#8a7551]" },
available: { text: "좋아, 이 이름은 비어 있어! ✨", cls: "text-[#5c8a2a]" },
taken: { text: "앗, 이미 다른 마을이 쓰고 있는 이름이야 🙀", cls: "text-[#b0472a]" },
offline: { text: "서버에 닿지 않아… 잠시 후 다시 시도해줘", cls: "text-[#b0472a]" },
offline: { text: "확인은 못 했지만 일단 시작할 수 있어! (나중에 자동 확인)", cls: "text-[#8a6316]" },
},
recover: {
idle: { text: "예전에 쓰던 마을 이름을 알려줘!", cls: "text-[#8a7551]" },
+5 -1
파일 보기
@@ -598,7 +598,8 @@ export const engine = {
if (state.happinessUnlocked) {
if (st.fullness >= 80)
st.happiness = Math.min(100, st.happiness + (1 / 60) * gainMul * dt);
if (st.fullness <= 0)
// 굶주림 하락 — 파업 중엔 제외 (회복이 하락을 못 이기면 영구 파업 데드락)
if (st.fullness <= 0 && cat.state !== "striking")
st.happiness = Math.max(0, st.happiness - (4 / 60) * decayMul * dt);
// 고양이 집 부족: 하락하되 이 요인만으로는 파업까지 가지 않도록 바닥 25 유지
if (
@@ -631,6 +632,9 @@ export const engine = {
cat.assignedTo = null;
cat.state = "striking";
}
// 파업 중엔 놀면서 서서히 기분이 풀림 (전원 파업 데드락 방지)
if (cat.state === "striking")
st.happiness = Math.min(100, st.happiness + (2 / 60) * dt);
if (cat.state === "striking" && st.happiness >= STRIKE_RESUME)
tryReturn(cat);
} // happinessUnlocked
+17 -6
파일 보기
@@ -38,16 +38,27 @@ export async function registerNickname(
export async function pushSave(): Promise<void> {
const s = engine.peek();
if (!s?.nickname || !s.syncToken) return;
const body = JSON.stringify({
nickname: s.nickname,
token: s.syncToken,
data: s,
});
try {
await fetch("/api/village/save", {
const res = await fetch("/api/village/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
nickname: s.nickname,
token: s.syncToken,
data: s,
}),
body,
});
if (res.status === 403) {
// 오프라인 상태에서 시작해 서버에 등록되지 않은 닉네임 — 재등록 후 재시도
const ok = await registerNickname(s.nickname, s.syncToken);
if (ok)
await fetch("/api/village/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
});
}
} catch {
// 오프라인이면 다음 주기에 재시도
}