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