diff --git a/packages/common/package.json b/packages/common/package.json index 7f09d73f..d433f634 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -10,7 +10,7 @@ "dev": "tsdown -c ../../tsdown.config.ts -F @sammo-ts/common --watch", "lint": "node -e \"console.log('lint not configured')\"", "test": "vitest run --config vitest.config.ts", - "typecheck": "tsc --noEmit" + "typecheck": "tsc -b" }, "dependencies": { "js-sha512": "^0.9.0" diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index bd23b9e9..355e3dee 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -4,6 +4,7 @@ export * from './util/BytesLike.js'; export * from './util/convertBytesLikeToArrayBuffer.js'; export * from './util/convertBytesLikeToUint8Array.js'; export * from './util/LiteHashDRBG.js'; +export * from './util/JosaUtil.js'; export * from './util/RNG.js'; export * from './util/RandUtil.js'; export * from './util/TestRNG.js'; diff --git a/packages/common/src/util/JosaUtil.ts b/packages/common/src/util/JosaUtil.ts new file mode 100644 index 00000000..a8700f29 --- /dev/null +++ b/packages/common/src/util/JosaUtil.ts @@ -0,0 +1,132 @@ +export type JosaKey = + | '은' + | '이' + | '과' + | '이나' + | '을' + | '으로' + | '이라' + | '이랑'; + +const DEFAULT_POSTPOSITION: Record = { + 은: '는', + 이: '가', + 과: '와', + 이나: '나', + 을: '를', + 으로: '로', + 이라: '라', + 이랑: '랑', +}; + +const buildMapPostPosition = (): Record => { + const map: Record = {}; + for (const [key, value] of Object.entries(DEFAULT_POSTPOSITION)) { + const k = key as JosaKey; + map[key] = k; + map[value] = k; + map[`(${key})${value}`] = k; + } + return map; +}; + +const MAP_POSTPOSITION = buildMapPostPosition(); + +const REG_INVALID_CHAR = /[^a-zA-Z0-9ㄱ-ㅎ가-힣一-廓\s]+/gu; +const REG_TARGET_CHAR = /^[\s\S]*?(\S*)\s*$/u; + +const KO_START_CODE = 0xac00; +const KO_FINISH_CODE = 0xd7a3; +const JONGSUNG_RIEUL = 8; + +const getLastChar = (text: string): string => { + const cleaned = text + .replace(REG_INVALID_CHAR, ' ') + .replace(REG_TARGET_CHAR, '$1') + .trim(); + if (!cleaned) { + return ''; + } + const chars = Array.from(cleaned); + return chars[chars.length - 1] ?? ''; +}; + +const getDigitJongsung = ( + digit: number +): { has: boolean; rieul: boolean } => { + switch (digit) { + case 0: + case 3: + case 6: + return { has: true, rieul: false }; + case 1: + case 7: + case 8: + return { has: true, rieul: true }; + default: + return { has: false, rieul: false }; + } +}; + +const hasJongsung = (text: string, isRo: boolean): boolean => { + const lastChar = getLastChar(text); + if (!lastChar) { + return false; + } + const code = lastChar.codePointAt(0); + if (code === undefined) { + return false; + } + if (code >= KO_START_CODE && code <= KO_FINISH_CODE) { + const jongsung = (code - KO_START_CODE) % 28; + if (jongsung === 0) { + return false; + } + if (isRo && jongsung === JONGSUNG_RIEUL) { + return false; + } + return true; + } + if (lastChar >= 'ㄱ' && lastChar <= 'ㅎ') { + if (isRo && lastChar === 'ㄹ') { + return false; + } + return true; + } + if (lastChar >= '0' && lastChar <= '9') { + const { has, rieul } = getDigitJongsung(Number(lastChar)); + if (isRo && rieul) { + return false; + } + return has; + } + const lower = lastChar.toLowerCase(); + const isVowel = ['a', 'e', 'i', 'o', 'u', 'y'].includes(lower); + return !isVowel; +}; + +export class JosaUtil { + static pick(text: string | null | undefined, wJongsung: string, woJongsung = ''): string { + const normalizedText = text ?? ''; + let withJongsung = wJongsung; + let withoutJongsung = woJongsung; + + if (!withoutJongsung) { + const mapped = MAP_POSTPOSITION[wJongsung]; + if (!mapped) { + throw new Error('올바르지 않은 조사 지정'); + } + withJongsung = mapped; + withoutJongsung = DEFAULT_POSTPOSITION[mapped]; + } + + const isRo = withJongsung === '으로'; + return hasJongsung(normalizedText, isRo) + ? withJongsung + : withoutJongsung; + } + + static put(text: string, wJongsung: string, woJongsung = ''): string { + return text + JosaUtil.pick(text, wJongsung, woJongsung); + } +} diff --git a/packages/logic/src/actions/turn/nation/che_발령.ts b/packages/logic/src/actions/turn/nation/che_발령.ts index 5e711826..6f385877 100644 --- a/packages/logic/src/actions/turn/nation/che_발령.ts +++ b/packages/logic/src/actions/turn/nation/che_발령.ts @@ -27,6 +27,7 @@ import type { } from '../../engine.js'; import { createGeneralPatchEffect, createLogEffect } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import { JosaUtil } from '@sammo-ts/common'; export interface AssignmentArgs { destGeneralId: number; @@ -103,6 +104,8 @@ export class ActionResolver< const cityName = this.env.formatCityName ? this.env.formatCityName(destCity) : destCity.name; + const cityJosa = JosaUtil.pick(cityName, '로'); + const generalJosa = JosaUtil.pick(destGeneral.name, '을'); const yearMonth = resolveLastAssignment(context); const effects: Array> = [ @@ -117,7 +120,7 @@ export class ActionResolver< effects.push( createLogEffect( - `${context.general.name}에 의해 ${cityName}로 발령됐습니다.`, + `${context.general.name}에 의해 ${cityName}${cityJosa} 발령됐습니다.`, { scope: LogScope.GENERAL, category: LogCategory.ACTION, @@ -128,7 +131,7 @@ export class ActionResolver< ); effects.push( createLogEffect( - `${destGeneral.name}을 ${cityName}로 발령했습니다.`, + `${destGeneral.name}${generalJosa} ${cityName}${cityJosa} 발령했습니다.`, { scope: LogScope.GENERAL, category: LogCategory.ACTION, diff --git a/packages/logic/src/actions/turn/nation/che_포상.ts b/packages/logic/src/actions/turn/nation/che_포상.ts index 448d3274..2855f0b8 100644 --- a/packages/logic/src/actions/turn/nation/che_포상.ts +++ b/packages/logic/src/actions/turn/nation/che_포상.ts @@ -32,6 +32,7 @@ import { createNationPatchEffect, } from '../../engine.js'; import { LogCategory, LogFormat, LogScope } from '../../../logging/types.js'; +import { JosaUtil } from '@sammo-ts/common'; export interface AwardArgs { isGold: boolean; @@ -148,9 +149,10 @@ export class ActionResolver< } as Partial, nation.id), ]; + const amountJosa = JosaUtil.pick(amountText, '을'); effects.push( createLogEffect( - `${label} ${amountText} 포상으로 받았습니다.`, + `${label} ${amountText}${amountJosa} 포상으로 받았습니다.`, { scope: LogScope.GENERAL, category: LogCategory.ACTION, @@ -161,7 +163,7 @@ export class ActionResolver< ); effects.push( createLogEffect( - `${context.destGeneral.name}에게 ${label} ${amountText} 수여했습니다.`, + `${context.destGeneral.name}에게 ${label} ${amountText}${amountJosa} 수여했습니다.`, { scope: LogScope.GENERAL, category: LogCategory.ACTION,