Skip to content

Commit dcfdd67

Browse files
committed
화면에 그대로 나가던 조사 자리표시자와 붙어 나오던 두 문장을 고침
남은 세 곳을 봤다. 사건·야영지 화면, 전투 진행, 데스크톱 폭이다. 전투는 라운드가 넘어가고 장벽이 깎이는 것까지 멀쩡했는데, 사건을 풀고 돌아오는 길에 문장이 두 군데 깨져 있었다. **`새싹몬이(가) 고른 길과 기록을 안고 집으로 돌아왔어요.`** 귀환 요약이다. 조사를 못 고르고 자리표시자를 그대로 내보냈다. 저장소를 훑으니 같은 것이 일곱 군데 있었다. - 귀환 요약 `이(가)` - 전투 방어 캡션 `이(가)`, 재사용 안내 `은(는)` 둘 - 야영지 짝 대화 나레이션 `와` + `이(가)` - 집중력 부족 안내 `이(가)` - 기록서 획득 안내 `을(를)` 조사 고르는 함수가 없어서가 아니었다. 앱에는 `core/text/korean_particles.dart`가 있고 서버에도 `_has_final_consonant`가 있는데, 서버 것은 `prompts.py` 안에 갇혀 있어서 다른 곳이 못 썼다. 꺼내서 `app/core/korean.py`로 옮기고 앱과 같은 네 조사를 갖췄다. 숫자도 한국어로 읽었을 때의 받침을 따른다 - `집중력 2가 필요해요`가 되어야지 `2이`가 아니다. 양쪽에 소스를 훑는 검사를 붙였다. 손으로 일곱 군데를 찾았는데 여덟 번째를 못 찾을 이유가 없다. 실제로 검사를 붙이고 나서야 `은(는)` 두 개를 더 찾았다. **`차분히 길을 열었어요 이제 기록을 안고 돌아갈 수 있어요.`** 사건 결과 화면이다. 앱이 서버가 준 결과 한 줄 뒤에 다음 문장을 붙이는데 (`expedition_stage_scene.dart`) 앞 문장에 마침표가 없었다. 바로 아래 줄의 대체 문구는 `이 걸음의 일을 마쳤어요. 기록을 안고…`로 마침표가 있으니 의도는 분명했다. 서버가 완결된 문장으로 보내게 했다 - 사건 화면은 같은 값을 한 줄로 그대로 쓰는데 거기서도 옆의 대체 문구와 어긋나 있었다. **데스크톱에서 몽그루가 두 번 나온다.** 840px부터 왼쪽 레일이 서고 그 머리에 브랜드가 있는데, 홈 앱바의 워드마크가 그대로 남아 한 화면에 둘이다. 다른 네 탭은 앱바에 화면 이름을 쓰니 홈만 어긋난다. 레일이 서는 폭에서는 `오늘`로 바꿨다. 기준 폭은 셸이 이미 쓰던 값을 상수로 꺼내 양쪽이 같은 값을 본다.
1 parent 7169e35 commit dcfdd67

11 files changed

Lines changed: 182 additions & 13 deletions

File tree

app/lib/core/routing/app_shell.dart

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@ class AppShell extends StatelessWidget {
99

1010
final StatefulNavigationShell navigationShell;
1111

12+
/// 아래쪽 탭 대신 왼쪽 레일을 쓰기 시작하는 폭.
13+
///
14+
/// 레일이 서면 브랜드가 레일 머리에 이미 있다. 화면 쪽에서 앱바에 워드마크를
15+
/// 또 그리지 않으려고 같은 값을 본다.
16+
static const double railBreakpoint = 840;
17+
1218
@override
1319
Widget build(BuildContext context) {
1420
return LayoutBuilder(
1521
builder: (context, constraints) {
16-
if (constraints.maxWidth >= 840) {
22+
if (constraints.maxWidth >= railBreakpoint) {
1723
return Scaffold(
1824
body: Row(
1925
children: [

app/lib/features/expedition/domain/expedition_models.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import '../../../core/text/korean_particles.dart';
12
import 'expedition_combat_models.dart';
23
export 'expedition_combat_models.dart';
34
export 'expedition_stage_models.dart';
@@ -268,7 +269,8 @@ class ExpeditionUnlockedSkillBook {
268269
String get sourceLabel => source == 'challenge' ? '도전 달성' : '조건 달성';
269270

270271
/// 안내 한 줄. 보유해도 자동 장착되지 않으므로 다음 행동까지 알려 준다.
271-
String get notice => '$name을(를) 서고에 담았어요. 기록서 화면에서 장착할 수 있어요.';
272+
String get notice =>
273+
'${koreanObject(name)} 서고에 담았어요. 기록서 화면에서 장착할 수 있어요.';
272274

273275
factory ExpeditionUnlockedSkillBook.fromJson(Map<String, dynamic> json) {
274276
final code = json['code'] as String? ?? '';

app/lib/features/expedition/presentation/expedition_battle_panel.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
44
import 'package:flutter/services.dart';
55
import 'package:flutter_riverpod/flutter_riverpod.dart';
66

7+
import '../../../core/text/korean_particles.dart';
78
import '../../../core/theme/app_theme.dart';
89
import '../../../core/theme/mongroo_ui.dart';
910
import '../../home/domain/plant.dart';
@@ -138,7 +139,8 @@ class _ExpeditionBattlePanelState extends ConsumerState<ExpeditionBattlePanel> {
138139
valid: false,
139140
focusAfter: focus,
140141
damage: damage,
141-
error: '${member.name} 순서에서 집중력 ${action.focusCost}이(가) 필요해요.',
142+
error: '${member.name} 순서에서 '
143+
'집중력 ${koreanSubject('${action.focusCost}')} 필요해요.',
142144
);
143145
}
144146
focus -= action.focusCost;

app/lib/features/home/presentation/home_screen.dart

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
22
import 'package:flutter_riverpod/flutter_riverpod.dart';
33
import 'package:go_router/go_router.dart';
44

5+
import '../../../core/routing/app_shell.dart';
56
import '../../../core/branding/mongroo_brand.dart';
67
import '../../../core/error/api_exception.dart';
78
import '../../../core/theme/app_theme.dart';
@@ -164,7 +165,11 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
164165
appBar: AppBar(
165166
toolbarHeight: 62,
166167
titleSpacing: 20,
167-
title: const _HomeWordmark(),
168+
// 레일이 서는 폭에서는 브랜드가 레일 머리에 이미 있다. 앱바에 또
169+
// 그리면 한 화면에 몽그루가 두 번 나온다.
170+
title: MediaQuery.sizeOf(context).width >= AppShell.railBreakpoint
171+
? const Text('오늘')
172+
: const _HomeWordmark(),
168173
actions: [
169174
_SeedToken(value: seedBalance),
170175
const SizedBox(width: 4),

app/test/home_harvest_guard_test.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,4 +222,43 @@ void main() {
222222
);
223223
expect(tester.takeException(), isNull);
224224
});
225+
226+
testWidgets('레일이 서는 폭에서는 앱바에 브랜드를 또 그리지 않는다', (tester) async {
227+
// 데스크톱에서는 왼쪽 레일 머리에 이미 몽그루가 있다. 홈 앱바의
228+
// 워드마크까지 그리면 한 화면에 브랜드가 두 번 나온다.
229+
final repository = _DelayedHarvestRepository(_harvestablePlant());
230+
Future<void> pumpAt(Size size) async {
231+
tester.view.physicalSize = size;
232+
tester.view.devicePixelRatio = 1;
233+
await tester.pumpWidget(
234+
ProviderScope(
235+
overrides: [
236+
plantRepositoryProvider.overrideWithValue(repository),
237+
authControllerProvider.overrideWith(_SignedInAuthController.new),
238+
farmControllerProvider.overrideWith(_IdleFarmController.new),
239+
],
240+
child: MaterialApp(
241+
theme: AppTheme.light(),
242+
builder: (context, child) => MediaQuery(
243+
data: MediaQuery.of(context).copyWith(disableAnimations: true),
244+
child: child!,
245+
),
246+
home: const HomeScreen(),
247+
),
248+
),
249+
);
250+
await tester.pump();
251+
await tester.pump(const Duration(milliseconds: 50));
252+
}
253+
254+
addTearDown(tester.view.reset);
255+
256+
await pumpAt(const Size(390, 844));
257+
expect(find.text('몽그루'), findsOneWidget);
258+
expect(find.text('오늘'), findsNothing);
259+
260+
await pumpAt(const Size(1280, 900));
261+
expect(find.text('몽그루'), findsNothing);
262+
expect(find.text('오늘'), findsOneWidget);
263+
});
225264
}

app/test/korean_particles_test.dart

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import 'dart:io';
2+
13
import 'package:flutter_test/flutter_test.dart';
24
import 'package:mongroo/core/text/korean_particles.dart';
35

@@ -15,4 +17,24 @@ void main() {
1517
expect(koreanTopic('해답이'), '해답이는');
1618
expect(koreanTopic('별솔'), '별솔은');
1719
});
20+
21+
test('자리표시자를 화면 문자열에 그대로 두지 않는다', () {
22+
// 서버 쪽에서 `새싹몬이(가) 고른 길과…`가 그대로 나갔다. 앱에도 같은
23+
// 모양이 하나 있었다 - `집중력 2이(가) 필요해요`.
24+
final lib = Directory('lib');
25+
const placeholders = ['이(가)', '을(를)', '은(는)', '와(과)', '과(와)'];
26+
final offenders = <String>[];
27+
for (final entity in lib.listSync(recursive: true)) {
28+
if (entity is! File || !entity.path.endsWith('.dart')) continue;
29+
// 조사 헬퍼는 규칙을 설명하느라 이름을 적어 둘 수 있다.
30+
if (entity.path.endsWith('korean_particles.dart')) continue;
31+
final text = entity.readAsStringSync();
32+
for (final placeholder in placeholders) {
33+
if (text.contains(placeholder)) {
34+
offenders.add('${entity.path}: $placeholder');
35+
}
36+
}
37+
}
38+
expect(offenders, isEmpty);
39+
});
1840
}

server/app/content/expeditions/combat.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
)
7474
from app.content.expeditions.skill_books import SKILL_BOOK_CATALOG
7575
from app.content.expeditions.tangles import CONTACT_MATERIALS, tangle_definition
76+
from app.core.korean import korean_subject, korean_topic
7677

7778

7879
# 전용 재질이 없는 구형 수호자에게 쓸 안전 기본값. 단단한 무언가에 닿았다는
@@ -1587,7 +1588,7 @@ def _apply_member_command(
15871588
member_state["guard"] = int(kit["guard"]["guard"])
15881589
pending["guard_actions"] = int(pending.get("guard_actions", 0)) + 1
15891590
action_name = kit["guard"]["name"]
1590-
caption = f"{actor_name}이(가) 마음을 다잡고 공격에 대비했어요."
1591+
caption = f"{korean_subject(actor_name)} 마음을 다잡고 공격에 대비했어요."
15911592
else:
15921593
skill_by_slot = {
15931594
item["slot"]: item
@@ -1608,9 +1609,9 @@ def _apply_member_command(
16081609
once_per_battle = remaining >= int(state.get("max_rounds", 6))
16091610
raise CombatRuleError(
16101611
"EXPEDITION_COMBAT_COOLDOWN",
1611-
f"{action_name}은(는) 이 전투에서 한 번만 쓸 수 있어요."
1612+
f"{korean_topic(action_name)} 이 전투에서 한 번만 쓸 수 있어요."
16121613
if once_per_battle
1613-
else f"{action_name}은(는) {remaining}턴 뒤 다시 사용할 수 있어요.",
1614+
else f"{korean_topic(action_name)} {remaining}턴 뒤 다시 사용할 수 있어요.",
16141615
)
16151616
# 고를 것이 있는 기록서는 무엇을 골랐는지 먼저 본다. 집중력을
16161617
# 쓰기 전에 막아야 잘못 고른 선택으로 자원이 사라지지 않는다.

server/app/content/expeditions/relationship_story.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import hashlib
1111
from typing import Any, Mapping, Sequence
1212

13+
from app.core.korean import korean_subject, korean_with
14+
1315

1416
RELATIONSHIP_MOMENTS = ("departure", "camp", "return")
1517

@@ -281,7 +283,8 @@ def relationship_duet(
281283
},
282284
]
283285
narration = (
284-
f"야영지의 불빛이 낮아지자 {left_name}{right_name}이(가) "
286+
f"야영지의 불빛이 낮아지자 {korean_with(left_name)} "
287+
f"{korean_subject(right_name)} "
285288
"평소 미뤄 둔 질문을 하나씩 꺼내요."
286289
)
287290
title = "불빛 곁의 깊은 이야기"

server/app/core/korean.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""한국어 조사 선택.
2+
3+
앱 쪽 `core/text/korean_particles.dart`와 같은 규칙이다. 서버에서도 이름을
4+
문장에 넣는 자리가 늘면서 `새싹몬이(가)`처럼 자리표시자가 그대로 화면에 나갔다.
5+
`prompts.py` 안에만 있던 것을 꺼내 한 곳에서 쓴다.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
#: 한국어로 읽었을 때 받침으로 끝나는 숫자. 영·일·삼·육·칠·팔.
11+
_DIGITS_WITH_FINAL = frozenset("013678")
12+
13+
14+
def has_final_consonant(word: str) -> bool:
15+
"""마지막 글자에 받침이 있는지."""
16+
trimmed = word.rstrip()
17+
if not trimmed:
18+
return False
19+
last = trimmed[-1]
20+
code_point = ord(last)
21+
if 0xAC00 <= code_point <= 0xD7A3:
22+
return (code_point - 0xAC00) % 28 != 0
23+
# 숫자는 한국어로 읽었을 때의 받침 유무를 따른다.
24+
if last.isdigit():
25+
return last in _DIGITS_WITH_FINAL
26+
return False
27+
28+
29+
def korean_subject(word: str) -> str:
30+
"""주격 조사 이/가."""
31+
return f"{word}{'이' if has_final_consonant(word) else '가'}"
32+
33+
34+
def korean_object(word: str) -> str:
35+
"""목적격 조사 을/를."""
36+
return f"{word}{'을' if has_final_consonant(word) else '를'}"
37+
38+
39+
def korean_with(word: str) -> str:
40+
"""동반격 조사 과/와."""
41+
return f"{word}{'과' if has_final_consonant(word) else '와'}"
42+
43+
44+
def korean_topic(word: str) -> str:
45+
"""보조사 은/는."""
46+
return f"{word}{'은' if has_final_consonant(word) else '는'}"

server/app/services/expeditions.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969
from app.services import rewards
7070
from app.services.adventure import ITEMS, character_stats
7171
from app.services.plants import growth_state_payload, level_from_exp, stage_from_exp
72+
from app.core.korean import korean_subject
7273

7374

7475
_CONTENT_PATH = (
@@ -79,11 +80,17 @@
7980
/ "moss_archive.json"
8081
)
8182
_STAT_LABELS = {"care": "돌봄", "focus": "집중", "courage": "용기", "insight": "관찰"}
83+
#: 사건 결과 한 줄. **완결된 문장으로 보낸다.**
84+
#:
85+
#: 앱이 이 값 뒤에 다음 문장을 붙인다(`expedition_stage_scene.dart`). 마침표가
86+
#: 없으면 `차분히 길을 열었어요 이제 기록을 안고 돌아갈 수 있어요.`처럼 두
87+
#: 문장이 붙어 나온다. 사건 화면(`expedition_decision.dart`)은 이 값을 그대로
88+
#: 한 줄로 쓰는데 옆의 대체 문구는 마침표로 끝나서 서로 어긋나기도 했다.
8289
_OUTCOME_LABELS = {
83-
"flourish": "멋지게 풀어냈어요",
84-
"clear": "차분히 길을 열었어요",
85-
"detour": "뜻밖의 우회로가 생겼어요",
86-
"safe": "안전한 선택으로 물러났어요",
90+
"flourish": "멋지게 풀어냈어요.",
91+
"clear": "차분히 길을 열었어요.",
92+
"detour": "뜻밖의 우회로가 생겼어요.",
93+
"safe": "안전한 선택으로 물러났어요.",
8794
}
8895
_STAT_APPROACH = {
8996
"focus": "careful",
@@ -2504,7 +2511,7 @@ async def _return_scene(db: AsyncSession, run: ExpeditionRun) -> dict[str, Any]:
25042511
"code": f"{run.region_code}.homeward.{min(len(names), 3)}",
25052512
"title": "함께 돌아온 탐험대",
25062513
"caption": (
2507-
f"{', '.join(names)}이(가) 고른 길과 기록을 안고 집으로 돌아왔어요."
2514+
f"{korean_subject(', '.join(names))} 고른 길과 기록을 안고 집으로 돌아왔어요."
25082515
if names
25092516
else "기록 안내자가 탐험의 흔적을 안고 돌아왔어요."
25102517
),

0 commit comments

Comments
 (0)