6.3 日期、数字、货币与 RTL

翻译对了不等于读得对:07/04/2026 在美国是 7 月 4 日、在英国是 4 月 7 日,1,234.56 在德国要写成 1.234,56。本节把日期、数字分组、货币符号与 RTL 方向整理成一张可机读的 locale 表,用 P 层 TypeScript 内核以全整数(最小单位 + 千分比汇率、地板除、无浮点)复算,并与 Python 参考实现逐字符对拍。

站内那篇独立游戏全球财务与多币种架构 讲的是钱怎么收、怎么结、怎么合规——收款通道、多币种账户、税务与分成。本节不碰那些,只管一件更小的事:同一笔钱、同一个日期,在不同地区该怎么写出来。这是「翻译之外」的第二层本地化:文字全对,格式全错,玩家一样读不懂,甚至会把价格读成另一个数。

上一节的游戏本地化与多语言指南 解决的是「说什么语言」,本节解决的是「用什么格式写」。

格式是翻译之外的第二层

一个真实事故的骨架:商店页写 07/04/2026,美区玩家读成 7 月 4 日,欧区玩家读成 4 月 7 日;同一页面写 1,234.56,德区玩家读成一千二百三十四点五六,还是读成 1.23456?标点符号的语义在地区之间是互换的。日期、数字、货币、方向这四个轴,每一个都有「看似通用、实则局部」的默认值。

轴变化的量踩坑示例
日期顺序 MDY/DMY/YMD、分隔符07/04/2026 与 2026/01/09
数字分组最右组位数、其余组位数印度 1,23,45,678(3+2),其余 12,345,678
小数点 / 千分位两个分隔符互换德区 1.234,56
货币最小单位、符号位置、空格日元/韩元没有小数位:¥193,825
方向dir=rtl 时整段右起阿语段落里的数字串会被双向算法重排

一张可机读的 locale 表

把上述五个轴写成纯数据。groupFirst 是最右一组的位数,groupRest 是其余每组的位数(印度是 3+2);currency.minorPerUnit 是「1 个大单位等于多少个最小单位」(日元/韩元为 1,其余为 100);fx.ratePermille 是「1 源币大单位兑多少目标币大单位」的千分比整数:

{
  "version": 1,
  "game": "merge-island",
  "observedAt": "2026-10",
  "note": "日期 / 数字 / 货币 / RTL 格式化表。口径是「公开可观察机制」的建模假设:把各地区公开的书写习惯整理成可机读的 locale 表。dir=ltr/rtl;dateOrder=MDY/DMY/YMD;dateSep/groupSep/decimalSep=分隔符;groupFirst=最右分组位数、groupRest=其余分组位数(印度用 3+2);currency.minorPerUnit=1 元最小单位数(日元/韩元为 1,其余 100);symbolFirst=货币符号是否在数字前;symbolSpace=符号与数字间是否留空格。fx.ratePermille=1 源币大单位兑多少目标币大单位(千分比,整数)。真实汇率逐秒变动、真实地区书写习惯需真实业务环境,命令行不可复现(X)。全整数,无浮点。",
  "locales": [
    { "id": "en_US", "name": "英语(美国)", "dir": "ltr", "dateOrder": "MDY", "dateSep": "/", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 3, "currency": { "code": "USD", "minorPerUnit": 100, "symbol": "$", "symbolFirst": true, "symbolSpace": false } },
    { "id": "en_GB", "name": "英语(英国)", "dir": "ltr", "dateOrder": "DMY", "dateSep": "/", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 3, "currency": { "code": "GBP", "minorPerUnit": 100, "symbol": "£", "symbolFirst": true, "symbolSpace": false } },
    { "id": "de_DE", "name": "德语(德国)", "dir": "ltr", "dateOrder": "DMY", "dateSep": ".", "groupSep": ".", "decimalSep": ",", "groupFirst": 3, "groupRest": 3, "currency": { "code": "EUR", "minorPerUnit": 100, "symbol": "€", "symbolFirst": false, "symbolSpace": true } },
    { "id": "ja_JP", "name": "日语(日本)", "dir": "ltr", "dateOrder": "YMD", "dateSep": "/", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 3, "currency": { "code": "JPY", "minorPerUnit": 1, "symbol": "¥", "symbolFirst": true, "symbolSpace": false } },
    { "id": "ko_KR", "name": "韩语(韩国)", "dir": "ltr", "dateOrder": "YMD", "dateSep": ".", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 3, "currency": { "code": "KRW", "minorPerUnit": 1, "symbol": "₩", "symbolFirst": true, "symbolSpace": false } },
    { "id": "in_IN", "name": "印地语(印度)", "dir": "ltr", "dateOrder": "DMY", "dateSep": "/", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 2, "currency": { "code": "INR", "minorPerUnit": 100, "symbol": "₹", "symbolFirst": true, "symbolSpace": false } },
    { "id": "ar_SA", "name": "阿拉伯语(沙特)", "dir": "rtl", "dateOrder": "DMY", "dateSep": "/", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 3, "currency": { "code": "SAR", "minorPerUnit": 100, "symbol": "ر.س", "symbolFirst": false, "symbolSpace": true } },
    { "id": "he_IL", "name": "希伯来语(以色列)", "dir": "rtl", "dateOrder": "DMY", "dateSep": ".", "groupSep": ",", "decimalSep": ".", "groupFirst": 3, "groupRest": 3, "currency": { "code": "ILS", "minorPerUnit": 100, "symbol": "₪", "symbolFirst": true, "symbolSpace": false } }
  ],
  "fx": [
    { "from": "USD", "to": "JPY", "ratePermille": 157000 },
    { "from": "GBP", "to": "EUR", "ratePermille": 1170 },
    { "from": "EUR", "to": "USD", "ratePermille": 1080 },
    { "from": "JPY", "to": "KRW", "ratePermille": 8600 },
    { "from": "KRW", "to": "JPY", "ratePermille": 116 },
    { "from": "INR", "to": "USD", "ratePermille": 12 },
    { "from": "SAR", "to": "USD", "ratePermille": 267 },
    { "from": "ILS", "to": "USD", "ratePermille": 270 }
  ],
  "samples": [
    { "id": "us", "locale": "en_US", "amountMinor": 123456, "y": 2026, "m": 7, "d": 4, "convertTo": "ja_JP" },
    { "id": "uk", "locale": "en_GB", "amountMinor": 99999, "y": 2026, "m": 12, "d": 31, "convertTo": "de_DE" },
    { "id": "de", "locale": "de_DE", "amountMinor": 123456, "y": 2026, "m": 3, "d": 1, "convertTo": "en_US" },
    { "id": "jp", "locale": "ja_JP", "amountMinor": 193825, "y": 2026, "m": 1, "d": 9, "convertTo": "ko_KR" },
    { "id": "kr", "locale": "ko_KR", "amountMinor": 45000, "y": 2026, "m": 5, "d": 5, "convertTo": "ja_JP" },
    { "id": "in", "locale": "in_IN", "amountMinor": 12345678, "y": 2026, "m": 8, "d": 15, "convertTo": "en_US" },
    { "id": "sa", "locale": "ar_SA", "amountMinor": 98765, "y": 2026, "m": 2, "d": 14, "convertTo": "en_US" },
    { "id": "il", "locale": "he_IL", "amountMinor": 5000, "y": 2026, "m": 9, "d": 3, "convertTo": "en_US" }
  ]
}

P 层:纯整数的格式化内核

核心只有一条纪律:金额一律用「最小单位整数」承载,汇率一律用「千分比整数」承载。分组用字符串切分而非 toLocaleString(后者依赖运行时 ICU 数据,跨机器会漂),换算用整数乘除加地板除,绝不出现浮点中间值:

// file: core/src/g06/format.ts
// tier: P
// verify: node --test 'core/test/g06_*.test.ts'
//
// 日期 / 数字 / 货币 / RTL 格式化内核:与 sim/g06_format_ref.py 逐位等价的 TypeScript 对偶实现。
// 读同一份 config/g06_format.json;全整数、地板除、无浮点、无随机、禁 Math.random。
// 金额一律用「最小单位整数」承载,汇率用「千分比整数」承载,杜绝浮点误差。

export interface Currency {
  readonly code: string; readonly minorPerUnit: number; readonly symbol: string;
  readonly symbolFirst: boolean; readonly symbolSpace: boolean;
}
export interface Locale {
  readonly id: string; readonly name: string; readonly dir: string;
  readonly dateOrder: string; readonly dateSep: string; readonly groupSep: string;
  readonly decimalSep: string; readonly groupFirst: number; readonly groupRest: number;
  readonly currency: Currency;
}
export interface Fx { readonly from: string; readonly to: string; readonly ratePermille: number }
export interface Sample {
  readonly id: string; readonly locale: string; readonly amountMinor: number;
  readonly y: number; readonly m: number; readonly d: number; readonly convertTo: string;
}
export interface FormatConfig {
  readonly version: number; readonly game: string;
  readonly locales: readonly Locale[]; readonly fx: readonly Fx[]; readonly samples: readonly Sample[];
}

export const LRI = "\u2066"; // LTR isolate
export const PDI = "\u2069"; // pop directional isolate

/** 从右往左分组:最右一组 first 位,其余每组 rest 位。 */
export function groupDigits(digits: string, sep: string, first: number, rest: number): string {
  if (digits.length <= first) return digits;
  const head = digits.slice(0, digits.length - first);
  const tail = digits.slice(digits.length - first);
  const groups: string[] = [];
  let h = head;
  while (h.length > rest) { groups.push(h.slice(h.length - rest)); h = h.slice(0, h.length - rest); }
  if (h.length > 0) groups.push(h);
  groups.reverse();
  return [...groups, tail].join(sep);
}

function pad(n: number, width: number): string {
  return String(n).padStart(width, "0");
}

/** 按 locale 格式化金额(最小单位整数 → 展示串)。 */
export function formatAmount(amountMinor: number, loc: Locale): string {
  const per = loc.currency.minorPerUnit;
  const major = Math.floor(amountMinor / per);
  const minor = amountMinor % per;
  let num = groupDigits(String(major), loc.groupSep, loc.groupFirst, loc.groupRest);
  if (per > 1) {
    const width = String(per).length - 1;
    num = `${num}${loc.decimalSep}${pad(minor, width)}`;
  }
  const gap = loc.currency.symbolSpace ? " " : "";
  const sym = loc.currency.symbol;
  return loc.currency.symbolFirst ? `${sym}${gap}${num}` : `${num}${gap}${sym}`;
}

/** 按 locale 的 dateOrder(MDY/DMY/YMD) 重排日期、补零、按 dateSep 连接。 */
export function formatDate(y: number, m: number, d: number, loc: Locale): string {
  const parts: Record<string, string> = { M: pad(m, 2), D: pad(d, 2), Y: pad(y, 4) };
  return loc.dateOrder.split("").map((c) => parts[c]).join(loc.dateSep);
}

/** 跨币种换算,落到目标币种最小单位;全整数地板除,无浮点。 */
export function convertMinor(amountMinor: number, src: Locale, dst: Locale, ratePermille: number): number {
  return Math.floor((amountMinor * ratePermille * dst.currency.minorPerUnit)
    / (1000 * src.currency.minorPerUnit));
}

/** RTL 段落里嵌 LTR 数字串:用 LRI…PDI 隔离,防止双向算法重排。 */
export function isolateLtr(text: string): string {
  return `${LRI}${text}${PDI}`;
}

export function dirOf(loc: Locale): string {
  return loc.dir;
}

function rateOf(cfg: FormatConfig, from: string, to: string): number {
  const fx = cfg.fx.find((f) => f.from === from && f.to === to);
  return fx === undefined ? 0 : fx.ratePermille;
}

/** 跨语言对拍摘要:与 sim/g06_format_ref.py 的 digest 逐字符一致。 */
export function digest(cfg: FormatConfig): string {
  const byId = new Map(cfg.locales.map((l) => [l.id, l]));
  return cfg.samples.map((s) => {
    const loc = byId.get(s.locale)!;
    const tgt = byId.get(s.convertTo)!;
    const conv = convertMinor(s.amountMinor, loc, tgt,
      rateOf(cfg, loc.currency.code, tgt.currency.code));
    return `${loc.id}:${formatAmount(s.amountMinor, loc)}:${formatDate(s.y, s.m, s.d, loc)}`
      + `:${formatAmount(conv, tgt)}`;
  }).join(";");
}

硬断言与跨语言对拍

测试不测「看起来像不像」,测的是跨语言逐字符一致:TS 内核复算的摘要必须等于 Python 参考实现 sim/g06_format_ref.py 落盘的金标准,外加分组、无小数币种、符号位置、日期重排、整数换算与 RTL 隔离六项定点断言:

// file: core/test/g06_format.test.ts
// tier: P
// verify: node --test 'core/test/g06_*.test.ts'
//
// 硬断言:TS 格式化内核复算的样本摘要,与 Python 侧 sim/g06_format_ref.py 落盘的金标准
// 逐字符一致;并验分组(含印度 3+2)、无小数币种、符号位置、RTL 隔离与整数换算。

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

import { digest, formatAmount, formatDate, convertMinor, groupDigits, isolateLtr,
  dirOf, LRI, PDI, type FormatConfig, type Locale } from "../src/g06/format.ts";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "g06_format.json"), "utf8")) as FormatConfig;
const loc = (id: string): Locale => cfg.locales.find((l) => l.id === id)!;

// Python 侧同机实跑落盘的金标准摘要
const EXPECTED = "en_US:$1,234.56:07/04/2026:¥193,825;en_GB:£999.99:31/12/2026:1.169,98 €;"
  + "de_DE:1.234,56 €:01.03.2026:$1,333.32;ja_JP:¥193,825:2026/01/09:₩1,666,895;"
  + "ko_KR:₩45,000:2026.05.05:¥5,220;in_IN:₹1,23,456.78:15/08/2026:$1,481.48;"
  + "ar_SA:987.65 ر.س:14/02/2026:$263.70;he_IL:₪50.00:03.09.2026:$13.50";

test("跨语言一致:TS 格式化摘要 == Python 金标准", () => {
  assert.equal(digest(cfg), EXPECTED);
});

test("分组:西式 3 位一隔 vs 印度 3+2 式", () => {
  assert.equal(groupDigits("12345678", ",", 3, 3), "12,345,678");
  assert.equal(groupDigits("12345678", ",", 3, 2), "1,23,45,678");
});

test("无小数币种:日元 / 韩元不带小数位", () => {
  assert.equal(formatAmount(193825, loc("ja_JP")), "¥193,825");
  assert.equal(formatAmount(45000, loc("ko_KR")), "₩45,000");
});

test("符号位置:德语符号在后带空格,阿拉伯语同理", () => {
  assert.equal(formatAmount(123456, loc("de_DE")), "1.234,56 €");
  assert.equal(formatAmount(98765, loc("ar_SA")), "987.65 ر.س");
  assert.equal(formatAmount(123456, loc("en_US")), "$1,234.56");
});

test("日期重排:MDY / DMY / YMD 三种顺序", () => {
  assert.equal(formatDate(2026, 7, 4, loc("en_US")), "07/04/2026");
  assert.equal(formatDate(2026, 7, 4, loc("en_GB")), "04/07/2026");
  assert.equal(formatDate(2026, 1, 9, loc("ja_JP")), "2026/01/09");
  assert.equal(formatDate(2026, 5, 5, loc("ko_KR")), "2026.05.05");
});

test("整数换算:USD 分 → JPY 円,地板除无浮点", () => {
  const usd = loc("en_US"); const jpy = loc("ja_JP");
  assert.equal(convertMinor(123456, usd, jpy, 157000), 193825);
  assert.equal(formatAmount(convertMinor(123456, usd, jpy, 157000), jpy), "¥193,825");
});

test("RTL:两个 rtl locale,且数字串用 LRI…PDI 隔离", () => {
  assert.equal(dirOf(loc("ar_SA")), "rtl");
  assert.equal(dirOf(loc("he_IL")), "rtl");
  assert.equal(dirOf(loc("en_US")), "ltr");
  const wrapped = isolateLtr("¥1,234");
  assert.equal(wrapped, `${LRI}¥1,234${PDI}`);
  assert.equal(wrapped.codePointAt(0), 0x2066);
  assert.equal(wrapped.codePointAt(wrapped.length - 1), 0x2069);
});

同机实跑(Node 26,strip-only 直跑 .ts):

$ node --test core/test/g06_format.test.ts
✔ 跨语言一致:TS 格式化摘要 == Python 金标准
✔ 分组:西式 3 位一隔 vs 印度 3+2 式
✔ 无小数币种:日元 / 韩元不带小数位
✔ 符号位置:德语符号在后带空格,阿拉伯语同理
✔ 日期重排:MDY / DMY / YMD 三种顺序
✔ 整数换算:USD 分 → JPY 円,地板除无浮点
✔ RTL:两个 rtl locale,且数字串用 LRI…PDI 隔离
ℹ tests 7
ℹ pass 7
ℹ fail 0

Python 参考实现(sim/g06_format_ref.py)对同一份配置产出的金标准摘要,逐字段格式为 locale:金额:日期:换算后金额:

en_US:$1,234.56:07/04/2026:¥193,825;en_GB:£999.99:31/12/2026:1.169,98 €;de_DE:1.234,56 €:01.03.2026:$1,333.32;ja_JP:¥193,825:2026/01/09:₩1,666,895;ko_KR:₩45,000:2026.05.05:¥5,220;in_IN:₹1,23,456.78:15/08/2026:$1,481.48;ar_SA:987.65 ر.س:14/02/2026:$263.70;he_IL:₪50.00:03.09.2026:$13.50

为什么必须纯整数

看一行真实输出就够了:1.15 * 100 在 IEEE-754 双精度下等于 114.99999999999999,Math.floor 之后是 114,而不是 115。也就是说,只要价格在系统里以「元」为单位的浮点数流转一次,1.15 元 就会在转成「分」时凭空少 1 分。这不是理论洁癖:0.1 + 0.2 在同样的表示下等于 0.30000000000000004。本节的做法是从入口就用最小单位整数(123456 表示 1234.56 元),汇率用千分比整数(157000 表示 157.000),换算全程整数乘除后地板除一次——没有任何中间浮点,结果与语言、平台、CPU 无关。

RTL 不只是「右对齐」

dir=rtl 只解决段落方向。真正的坑在于双向文本算法:一段阿语里嵌了一串 LTR 数字(价格、日期、ID),如果不用 LRI(U+2066,左到右隔离)与 PDI(U+2069,弹出隔离)把数字串包起来,数字内部的符号、负号、括号可能被算法重排到视觉上错误的位置——玩家看到的 $1,234 会变成 1234$ 之类。内核里 isolateLtr() 就干这一件事,测试里用 codePointAt 直接断言首尾确实是 U+2066 / U+2069,而不是看起来像隔离符的普通字符。表里的 ar_SA 与 he_IL 两个 locale 都标 rtl,dirOf() 把它们与其余六个 ltr 区分开。

这些数不能靠命令行复现(X)

locale 表与汇率表是**「公开可观察机制」的建模假设**,不是权威数据源:真实汇率逐秒变动,ratePermille 只是把某个观察时点(observedAt: 2026-10)的近似关系冻结成整数;真实地区书写习惯、平台支付渠道的最小单位与取整规则、以及真机上字体的缺字与断行表现,都需要真实业务环境与真实设备,命令行无法复现,标为 X。真正能复现的,是给定这张表之后的格式化与换算结果——那部分本节的 TS 内核与 Python 参考实现逐字符一致。

自检

检查项本节结论
是否用浮点承载金额否,全程最小单位整数;1.15*100 反例已实测
汇率是否整数是,千分比整数,换算后地板除一次
是否依赖运行时 ICU否,分组为字符串切分,跨机器稳定
RTL 是否只做右对齐否,数字串用 U+2066/U+2069 隔离并断言
跨语言是否一致是,TS 摘要 == Python 金标准,7/7 通过

本节把「格式」从「翻译的附属品」变成了一张可机读、可对拍的 locale 表。下一节进入 7.1,我们用伪本地化在开发期就把溢出与截断问题逼出来,而不是等上线后靠玩家截图。

阅读导航:上一节:6.2 美术与素材的本地化变体 · 下一节:7.1 伪本地化与溢出检测 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制