Skip to content

Commit db375a5

Browse files
committed
기록서가 건 잠금만 눌러 봐야 알 수 있던 것을 카드에 미리 적음
전투 카드의 잠금은 다 미리 말한다. 집중력이 모자라면 `집중 부족`, 재사용 대기면 `재사용 2`, 레벨이 모자라면 `Lv.9 해금`, 고를 후보가 비면 `지금은 고를 것이 없어요`. 그 자리에 사유를 넣은 코드에도 `눌러 본 뒤 거절당하는 대신 왜 못 쓰는지 슬롯에서 바로 읽히게 한다`고 적혀 있다. 기록서가 건 잠금 넷만 그 규칙 밖에 있었다. 발아 시계의 태엽(1라운드 스킬), 고리수 기록부(전투 내내 지키기), 아홉 꼬리의 잔상(그 라운드 지키기), 마음결 대백과(교체한 라운드 스킬)는 카드가 멀쩡히 열려 있다가 누르면 그제야 서버가 거절했다. 문장 자체는 친절한데, 고르기 전에 알아야 할 정보를 고른 뒤에 준다. `guardian_battle_payload`가 킷을 만들 때 같은 상태를 읽어 사유를 함께 싣는다. 판정은 그대로 `_apply_action`이 한다 - 여기서 하는 일은 말해 주는 것뿐이라 두 곳이 어긋날 여지를 두지 않으려고 상태를 같은 곳에서 읽는다. **이미 잠긴 칸은 건드리지 않는다.** 레벨이나 후보 없음처럼 더 오래가는 사유가 있으면 그쪽이 더 쓸모 있다. 실제로 발아 시계는 자기가 꽂힌 칸에 이미 `때가 되면 스스로 펼쳐져요`를 달고 있어서, 덮었다면 그 말이 사라졌다. 앱은 고칠 게 없었다. `lockReason`을 읽고 없을 때만 레벨 문구로 떨어지는 코드가 이미 있었다 - 사유를 안 보내 주고 있었을 뿐이다. 대신 앱 쪽에도 테스트를 붙여 레벨 문구로 바꿔 말하지 않는 것을 고정했다.
1 parent 0102db4 commit db375a5

3 files changed

Lines changed: 202 additions & 1 deletion

File tree

app/test/expedition_models_test.dart

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1517,6 +1517,57 @@ void main() {
15171517
await tester.pumpWidget(const SizedBox.shrink());
15181518
});
15191519

1520+
testWidgets('기록서가 건 잠금은 눌러 보기 전에 카드에서 읽힌다', (tester) async {
1521+
// 이 화면의 다른 잠금은 다 미리 말한다 - `집중 부족`, `Lv.N 해금`.
1522+
// 기록서가 건 잠금만 눌러야 알 수 있었다.
1523+
await tester.binding.setSurfaceSize(const Size(390, 1100));
1524+
addTearDown(() => tester.binding.setSurfaceSize(null));
1525+
final raw = _battleSnapshotJson();
1526+
final battleJson = (raw['current_event'] as Map<String, dynamic>)['battle']
1527+
as Map<String, dynamic>;
1528+
// 집중력을 넉넉히 줘 `집중 부족`이 대신 뜨지 않게 한다.
1529+
battleJson['focus'] = 5;
1530+
final kitJson = ((battleJson['party'] as List).first
1531+
as Map<String, dynamic>)['kit'] as Map<String, dynamic>;
1532+
final unique = (kitJson['unique_skills'] as List).first
1533+
as Map<String, dynamic>;
1534+
unique
1535+
..['available'] = false
1536+
..['lock_reason'] = '태엽 감는 중';
1537+
(kitJson['guard'] as Map<String, dynamic>)
1538+
..['available'] = false
1539+
..['lock_reason'] = '고리수 세는 중';
1540+
final snapshot = ExpeditionSnapshot.fromJson(raw);
1541+
1542+
await tester.pumpWidget(
1543+
ProviderScope(
1544+
overrides: [
1545+
expeditionControllerProvider.overrideWith(
1546+
() => _FakeExpeditionController(
1547+
ExpeditionUiState(
1548+
loading: false,
1549+
expedition: snapshot,
1550+
selectedMemberId: 11,
1551+
),
1552+
),
1553+
),
1554+
],
1555+
child: MaterialApp(
1556+
theme: AppTheme.light(),
1557+
home: const ExpeditionScreen(),
1558+
),
1559+
),
1560+
);
1561+
await tester.pump();
1562+
1563+
// 레벨 문구로 바꿔 말하지 않는다 - 서버가 준 사유를 그대로 쓴다.
1564+
expect(find.text('태엽 감는 중'), findsWidgets);
1565+
expect(find.text('고리수 세는 중'), findsWidgets);
1566+
expect(find.textContaining('해금'), findsNothing);
1567+
expect(tester.takeException(), isNull);
1568+
await tester.pumpWidget(const SizedBox.shrink());
1569+
});
1570+
15201571
testWidgets('잔향 읽기를 쓴 전투에서만 다음 라운드 예고가 보인다', (tester) async {
15211572
// 이 책이 파는 것은 이 한 줄뿐이다. 늘 보이면 40씨앗짜리 책이 파는 게
15221573
// 없어지고, 안 보이면 산 사람이 아무것도 못 받는다.

server/app/content/expeditions/combat.py

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ def member_battle_kit(
164164
choice_context: dict[str, dict[str, Any]] | None = None,
165165
enemy_guard_bp: int = 10_000,
166166
party_unique2_power: int = 0,
167+
skill_lock_reason: str | None = None,
168+
guard_lock_reason: str | None = None,
167169
) -> dict[str, Any]:
168170
element_kels = element_kel_map(kel_map_version)
169171
snapshot = profile.get("snapshot", {})
@@ -440,7 +442,7 @@ def resolve_skill(
440442
basic_neutral_power = scaled_power(
441443
scaled_power(basic_raw_power, basic_scale), TIER_POWER_BP[tier]
442444
)
443-
return {
445+
kit = {
444446
"version": 8,
445447
"kel_map_version": int(kel_map_version),
446448
"level": level,
@@ -535,6 +537,42 @@ def resolve_skill(
535537
"cooldown_remaining": 0,
536538
},
537539
}
540+
return _apply_state_locks(
541+
kit,
542+
skill_lock_reason=skill_lock_reason,
543+
guard_lock_reason=guard_lock_reason,
544+
)
545+
546+
547+
def _apply_state_locks(
548+
kit: dict[str, Any],
549+
*,
550+
skill_lock_reason: str | None,
551+
guard_lock_reason: str | None,
552+
) -> dict[str, Any]:
553+
"""기록서가 건 잠금을 슬롯에서 미리 읽히게 한다.
554+
555+
이 잠금들은 지금까지 `_apply_action`의 거절로만 존재했다. 즉 눌러 봐야
556+
알 수 있었는데, 이 화면의 다른 잠금은 전부 미리 말한다 - 집중력이 모자라면
557+
`집중 부족`, 레벨이 모자라면 `Lv.N 해금`이다. 기록서가 건 것만 예외일
558+
이유가 없다(같은 파일의 `고를 것이 없어요`도 같은 이유로 미리 말한다).
559+
560+
**이미 잠긴 슬롯은 건드리지 않는다.** 레벨이나 후보 없음처럼 더 오래가는
561+
사유가 이미 있으면 그쪽이 사용자에게 더 쓸모 있다. 판정은 그대로
562+
`_apply_action`이 하며, 여기서 하는 일은 말해 주는 것뿐이다.
563+
"""
564+
565+
def lock(action: dict[str, Any], reason: str) -> None:
566+
if action.get("available", True):
567+
action["available"] = False
568+
action["lock_reason"] = reason
569+
570+
if skill_lock_reason:
571+
for action in [*kit["unique_skills"], *kit["selected_skills"]]:
572+
lock(action, skill_lock_reason)
573+
if guard_lock_reason:
574+
lock(kit["guard"], guard_lock_reason)
575+
return kit
538576

539577

540578
def _resolve_waves(
@@ -1093,6 +1131,9 @@ def guardian_battle_payload(
10931131
if profile is None:
10941132
continue
10951133
snapshot = profile.get("snapshot", {})
1134+
skill_lock_reason, guard_lock_reason = _state_lock_reasons(
1135+
state, member_state
1136+
)
10961137
party.append(
10971138
{
10981139
**member_state,
@@ -1111,6 +1152,8 @@ def guardian_battle_payload(
11111152
round_number=int(state.get("round", 1)),
11121153
enemy_guard_bp=enemy_guard_bp,
11131154
party_unique2_power=party_unique2_power,
1155+
skill_lock_reason=skill_lock_reason,
1156+
guard_lock_reason=guard_lock_reason,
11141157
choice_context=_battle_choice_context(
11151158
state,
11161159
member_state=member_state,
@@ -1335,6 +1378,45 @@ def _target_members(
13351378
return [living[0]]
13361379

13371380

1381+
# 슬롯에 붙는 짧은 잠금 사유. 거절 문장과 같은 말을 쓰되 카드 배지 길이에
1382+
# 맞춘다(`집중 부족`·`재사용 2`와 같은 자리다). 왜 잠겼는지 눌러 보기 전에
1383+
# 읽히는 것이 목적이므로 원인을 지운 `사용 불가`로 뭉뜽그리지 않는다.
1384+
SKILL_LOCK_WINDING = "태엽 감는 중"
1385+
SKILL_LOCK_SWAPPING = "기록서 교체 중"
1386+
GUARD_LOCK_RINGCOUNT = "고리수 세는 중"
1387+
GUARD_LOCK_AFTERIMAGE = "잔상 받음"
1388+
1389+
1390+
def _state_lock_reasons(
1391+
state: dict[str, Any], member_state: dict[str, Any]
1392+
) -> tuple[str | None, str | None]:
1393+
"""이 대원의 스킬·지키기가 지금 왜 잠겼는지. 안 잠겼으면 `(None, None)`.
1394+
1395+
판정은 `_apply_action`이 그대로 한다. 여기서 만드는 것은 화면에 미리
1396+
보여 줄 사유뿐이고, 두 곳이 어긋나지 않도록 같은 상태를 읽는다.
1397+
"""
1398+
1399+
member_id = int(member_state.get("member_id", 0))
1400+
round_number = int(state.get("round", 1))
1401+
opening = state.get("skill_book_opening") or {}
1402+
1403+
skill_reason: str | None = None
1404+
if round_number <= 1 and member_id in {
1405+
int(value) for value in opening.get("first_round_skill_locked", [])
1406+
}:
1407+
skill_reason = SKILL_LOCK_WINDING
1408+
elif int(member_state.get("skill_blocked_round", 0)) == round_number:
1409+
skill_reason = SKILL_LOCK_SWAPPING
1410+
1411+
guard_reason: str | None = None
1412+
if member_id in {int(value) for value in opening.get("guard_locked", [])}:
1413+
guard_reason = GUARD_LOCK_RINGCOUNT
1414+
elif _retargeted_member_id(state) == member_id:
1415+
guard_reason = GUARD_LOCK_AFTERIMAGE
1416+
1417+
return skill_reason, guard_reason
1418+
1419+
13381420
def _retargeted_member_id(state: dict[str, Any]) -> int | None:
13391421
"""이번 라운드에 예고를 넘겨받은 대원. 라운드가 지나면 저절로 풀린다."""
13401422

server/tests/unit/test_skill_books.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,6 +560,74 @@ def test_germination_gear_locks_skills_in_the_first_round():
560560
)
561561

562562

563+
def test_book_locks_are_announced_on_the_slot_before_the_tap():
564+
"""눌러 본 뒤 거절당하는 대신 슬롯에서 왜 못 쓰는지 먼저 읽힌다.
565+
566+
이 화면의 다른 잠금은 전부 미리 말한다 - 집중력이 모자라면 `집중 부족`,
567+
레벨이 모자라면 `Lv.N 해금`이다. 기록서가 건 잠금만 예외였다.
568+
"""
569+
570+
from app.content.expeditions.combat import (
571+
GUARD_LOCK_RINGCOUNT,
572+
SKILL_LOCK_WINDING,
573+
guardian_battle_payload,
574+
new_guardian_battle,
575+
)
576+
577+
encounter = _encounter_fixture()
578+
579+
# 발아 시계 — 1라운드에 스킬 넷이 잠기고 기본 공격·지키기는 열려 있다.
580+
profiles = _party_with("germination_gear")
581+
battle = new_guardian_battle("gear", encounter, profiles)
582+
kit = guardian_battle_payload(battle, encounter, profiles)["party"][0]["kit"]
583+
for action in kit["unique_skills"]:
584+
assert action["available"] is False
585+
assert action["lock_reason"] == SKILL_LOCK_WINDING
586+
# 선택 칸도 잠기지만, 발아 시계 자신이 꽂힌 칸은 원래 사유를 지킨다
587+
# (`때가 되면 스스로 펼쳐져요`). 더 오래가는 사유를 덮지 않는 규칙이다.
588+
for action in kit["selected_skills"]:
589+
assert action["available"] is False
590+
assert action["lock_reason"]
591+
# 밸런스 불변식 - 최소 한 행동은 언제나 열려 있어야 한다.
592+
assert kit["basic"]["available"] is True
593+
assert kit["guard"]["available"] is True
594+
595+
# 대가를 지지 않은 대원은 잠기지도, 사유가 붙지도 않는다.
596+
other = guardian_battle_payload(battle, encounter, profiles)["party"][1]["kit"]
597+
assert other["unique_skills"][0]["available"] is True
598+
assert other["unique_skills"][0].get("lock_reason") is None
599+
600+
# 고리수 기록부 — 그 전투 내내 지키기가 잠긴다.
601+
ring_profiles = _b2_party("ringcount_record")
602+
ring = new_guardian_battle("ring", encounter, ring_profiles)
603+
ring_kit = guardian_battle_payload(ring, encounter, ring_profiles)["party"][0][
604+
"kit"
605+
]
606+
assert ring_kit["guard"]["available"] is False
607+
assert ring_kit["guard"]["lock_reason"] == GUARD_LOCK_RINGCOUNT
608+
609+
610+
def test_state_lock_does_not_overwrite_a_longer_lasting_reason():
611+
"""레벨처럼 더 오래가는 사유가 이미 있으면 그쪽을 남긴다."""
612+
613+
from app.content.expeditions.combat import _apply_state_locks
614+
615+
kit = {
616+
"unique_skills": [
617+
{"available": False, "lock_reason": None, "unlock_level": 7},
618+
{"available": True},
619+
],
620+
"selected_skills": [{"available": True}, {"available": True}],
621+
"guard": {"available": True},
622+
}
623+
_apply_state_locks(kit, skill_lock_reason="태엽 감는 중", guard_lock_reason=None)
624+
625+
# 이미 잠긴 첫 칸은 그대로 두고, 열려 있던 칸에만 사유가 붙는다.
626+
assert kit["unique_skills"][0]["lock_reason"] is None
627+
assert kit["unique_skills"][1]["lock_reason"] == "태엽 감는 중"
628+
assert kit["guard"]["available"] is True
629+
630+
563631
def test_germination_gear_lock_lifts_after_the_first_round():
564632
"""반대급부는 1라운드까지다. 그 뒤에는 평소처럼 쓴다."""
565633

0 commit comments

Comments
 (0)