Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion frontend/src/game.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,18 @@ export const createGameApp = () => {

keyDown(event: KeyboardEvent | { key: string }): void {
if (this.animating) return;
const key = event.key;

// Physical keyboard → character mapping (bypasses IME for Korean, etc.)
let key = event.key;
const physicalKeyMap = this.config?.physical_key_map;
if (physicalKeyMap && 'code' in event) {
const code = event.shiftKey ? `Shift${event.code}` : event.code;
const mapped = physicalKeyMap[code];
if (mapped) {
event.preventDefault();
key = mapped;
}
}

if (key === 'Escape') {
this.showHelpModal = false;
Expand Down
5 changes: 5 additions & 0 deletions frontend/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ export interface LanguageConfig {
* Example for Greek: { "σ": "ς" }
*/
final_form_map?: Record<string, string>;
/** Optional physical key → character map for bypassing IME composition.
* Maps KeyboardEvent.code values to characters.
* Example for Korean: { "KeyQ": "ㅂ", "ShiftKeyQ": "ㅃ", ... }
*/
physical_key_map?: Record<string, string>;
}

// =============================================================================
Expand Down
25 changes: 25 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,5 +156,30 @@ def load_keyboard(lang_code: str) -> list | None:
return data


def load_all_keyboard_chars(lang_code: str) -> set[str]:
"""Load all keyboard characters across ALL layouts for a language.

For coverage tests, a character is typeable if it appears on any layout.
"""
keyboard_file = LANGUAGES_DIR / lang_code / f"{lang_code}_keyboard.json"
if not keyboard_file.exists():
return set()
with open(keyboard_file, encoding="utf-8") as f:
data = json.load(f)

control_keys = {"⇨", "⟹", "⌫", "↵", "ENTER", "DEL"}
chars = set()

if isinstance(data, dict) and "layouts" in data:
for layout in data["layouts"].values():
for row in layout.get("rows", []):
chars.update(k for k in row if k not in control_keys)
elif isinstance(data, list):
for row in data:
chars.update(k for k in row if k not in control_keys)

return chars


# Make language codes available for parametrize
ALL_LANGUAGES = get_all_language_codes()
11 changes: 4 additions & 7 deletions tests/test_language_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from tests.conftest import (
ALL_LANGUAGES,
get_diacritic_base_chars,
load_all_keyboard_chars,
load_keyboard,
load_language_config,
load_word_list,
Expand Down Expand Up @@ -153,7 +154,7 @@ class TestKeyboardConfig:
"""Tests for keyboard configuration."""

# Languages with known keyboard coverage gaps (complex scripts, incomplete keyboards)
KEYBOARD_COVERAGE_XFAIL = {"ko", "de"}
KEYBOARD_COVERAGE_XFAIL: set[str] = set()

@pytest.mark.parametrize("lang", ALL_LANGUAGES)
def test_keyboard_covers_all_characters(self, lang):
Expand All @@ -178,12 +179,8 @@ def test_keyboard_covers_all_characters(self, lang):
for word in words:
word_chars.update(word)

# Flatten keyboard to get all keys
keyboard_chars = set()
for row in keyboard:
for key in row:
if key not in ("⇨", "⟹", "⌫", "ENTER", "DEL"):
keyboard_chars.add(key)
# Check all layouts — a character is typeable if it appears on any layout
keyboard_chars = load_all_keyboard_chars(lang)

# Get diacritic mapping - chars that can be typed via base char
diacritic_map = get_diacritic_base_chars(lang)
Expand Down
12 changes: 4 additions & 8 deletions tests/test_word_lists.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from tests.conftest import (
ALL_LANGUAGES,
get_diacritic_base_chars,
load_all_keyboard_chars,
load_blocklist,
load_characters,
load_daily_words,
Expand Down Expand Up @@ -124,7 +125,7 @@ class TestKeyboardCoverage:
"""Tests for keyboard coverage of word characters."""

# Languages with known keyboard coverage gaps (complex scripts)
KEYBOARD_COVERAGE_XFAIL: set[str] = {"ko", "de"}
KEYBOARD_COVERAGE_XFAIL: set[str] = set()

@pytest.mark.parametrize("lang", ALL_LANGUAGES)
def test_keyboard_covers_all_word_characters(self, lang):
Expand All @@ -146,13 +147,8 @@ def test_keyboard_covers_all_word_characters(self, lang):
if not keyboard or all(len(row) == 0 for row in keyboard):
pytest.skip(f"{lang}: Empty keyboard (app will auto-generate)")

# Extract all characters from keyboard (load_keyboard returns normalized rows)
keyboard_chars = set()
for row in keyboard:
for key in row:
# Skip control keys
if key not in ["⇨", "⟹", "⌫", "↵", "ENTER", "DEL"]:
keyboard_chars.add(key)
# Check all layouts — a character is typeable if it appears on any layout
keyboard_chars = load_all_keyboard_chars(lang)

if not keyboard_chars:
pytest.skip(f"{lang}: Empty keyboard layout")
Expand Down
27 changes: 24 additions & 3 deletions webapp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -672,12 +672,28 @@ def __init__(self, language_code, word_list, keyboard_layout=None):

self.characters = language_characters[language_code]
# remove chars that aren't used to reduce bloat a bit
characters_used = []
characters_used = set()
for word in self.word_list:
characters_used += list(word)
characters_used = list(set(characters_used))
characters_used.update(word)
self.characters = [char for char in self.characters if char in characters_used]

# Include diacritic base characters whose variants appear in the word list.
# This allows keyboards using base forms (e.g., Compatibility Jamo) to work
# with word lists using variant forms (e.g., Hangul Jamo).
#
# Languages using this mechanism:
# - Korean (ko): Compatibility Jamo ↔ Hangul Jamo (different Unicode blocks)
# - German (de): s ↔ ß (sharp S treated as variant of s)
# - European languages: base letters ↔ accented variants (a ↔ ä, o ↔ ö)
#
# Future: languages with composition-based scripts (e.g., Devanagari for Hindi,
# Thai, Khmer) may need similar keyboard↔wordlist normalization if added.
diacritic_map = self.config.get("diacritic_map", {})
chars_set = set(self.characters)
for base, variants in diacritic_map.items():
if base not in chars_set and any(v in characters_used for v in variants):
self.characters.append(base)

keyboard_config = keyboards.get(language_code, {"default": None, "layouts": {}})
self.keyboard_layouts = self._build_keyboard_layouts(keyboard_config)
self.keyboard_layout_name = self._select_keyboard_layout(
Expand Down Expand Up @@ -776,6 +792,11 @@ def _build_key_diacritic_hints(self):
for key in row:
keyboard_keys.add(key.lower())

# Hide diacritic hints when the map is used for encoding normalization
# (e.g., Korean Jamo) rather than player-visible accent variants.
if self.config.get("hide_diacritic_hints"):
return {}

hints = {}
for base_char, variants in diacritic_map.items():
if base_char.lower() in keyboard_keys:
Expand Down
2 changes: 1 addition & 1 deletion webapp/data/languages/de/language_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"u": [
"ü"
],
"ss": [
"s": [
"ß"
]
},
Expand Down
139 changes: 139 additions & 0 deletions webapp/data/languages/ko/ko_blocklist.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Korean compound jongseong blocklist
# These words contain compound final consonants (겹받침) like ㄺ, ㄻ, ㄼ, etc.
# that are not on the default keyboard layout. They remain valid guesses
# (playable via the Full keyboard layout) but are excluded from daily selection.
#
# Future improvement: decompose compound jongseong into individual jamo and
# use 6 cells per word (like 꼬들/kordle.kr does), which would eliminate the
# need for compound keys entirely. See ko_keyboard.json for details.
# Total: 129 words

갉다
갉아
값싸
값져
값해
곪다
곪아
곯다
곯려
곯아
굵기
굵다
굵어
굶겨
굶다
굶어
긁다
긁어
긁혀
기슭
까닭
꿇다
꿇려
꿇어
끊겨
끊다
끊어
끊여
끓다
끓어
끓여
낡다
낡아
넓다
넓어
넓이
넓혀
늙다
늙어
닭띠
닮다
닮아
닳다
닳아
떫다
떫어
뚫다
뚫려
뚫어
많다
많아
많이
맑다
맑아
묽다
묽어
밝기
밝다
밝아
밝혀
밝히
밟다
밟아
밟혀
붉다
붉어
붉혀
삶다
삶아
섧다
섧어
수탉
싫다
싫어
앉다
앉아
앉혀
않다
않아
앓다
앓아
얇다
얇아
얹다
얹어
얹혀
얽다
얽매
얽어
얽혀
없다
없애
없어
없이
여덟
엷다
엷어
옭다
옭매
옭아
옮겨
옮다
옮아
옳다
옳소
옳아
외곬
읊다
읊어
읽기
읽다
읽어
읽혀
잃다
잃어
젊다
젊어
제값
짊다
짊어
짧다
짧아
칡차
핥다
핥아
훑다
훑어
흙내
흙비
12 changes: 8 additions & 4 deletions webapp/data/languages/ko/ko_keyboard.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,19 @@
"rows": [
["ㅂ", "ㅈ", "ㄷ", "ㄱ", "ㅅ", "ㅛ", "ㅕ", "ㅑ", "ㅐ", "ㅔ"],
["ㅁ", "ㄴ", "ㅇ", "ㄹ", "ㅎ", "ㅗ", "ㅓ", "ㅏ", "ㅣ"],
["ㅃ", "ㅉ", "ㄸ", "ㄲ", "ㅆ", "ㅒ", "ㅖ"],
["ㅘ", "ㅙ", "ㅚ", "ㅝ", "ㅞ", "ㅟ", "ㅢ"],
["⇨", "ㅋ", "ㅌ", "ㅊ", "ㅍ", "ㅠ", "ㅜ", "ㅡ", "⌫"]
]
},
"korean_double": {
"label": "With Double Consonants",
"korean_full": {
"label": "Full (전체 자모)",
"rows": [
["ㅂ", "ㅃ", "ㅈ", "ㅉ", "ㄷ", "ㄸ", "ㄱ", "ㄲ", "ㅅ", "ㅆ", "ㅛ", "ㅕ", "ㅑ", "ㅐ", "ㅒ", "ㅔ", "ㅖ"],
["ㅂ", "ㅃ", "ㅈ", "ㅉ", "ㄷ", "ㄸ", "ㄱ", "ㄲ", "ㅅ", "ㅆ"],
["ㅛ", "ㅕ", "ㅑ", "ㅐ", "ㅒ", "ㅔ", "ㅖ", "ㅘ", "ㅙ", "ㅚ"],
["ㅁ", "ㄴ", "ㅇ", "ㄹ", "ㅎ", "ㅗ", "ㅓ", "ㅏ", "ㅣ"],
["⇨", "ㅋ", "ㅌ", "ㅊ", "ㅍ", "ㅠ", "ㅜ", "ㅡ", "⌫"]
["ㅝ", "ㅞ", "ㅟ", "ㅢ", "ㅋ", "ㅌ", "ㅊ", "ㅍ", "ㅠ", "ㅜ", "ㅡ"],
["⇨", "ㄵ", "ㄶ", "ㄺ", "ㄻ", "ㄼ", "ㄽ", "ㄾ", "ㄿ", "ㅀ", "ㅄ", "⌫"]
]
}
}
Expand Down
Loading
Loading