@zhin.js/plugin-dungeon-expedition 1.1.15 → 1.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/engine.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createMessages, type Locale } from 'zhin.js/i18n';
1
2
  import { DeterministicRandom } from '@zhin.js/game-kit';
2
3
 
3
4
  export const DUNGEON_SCHEMA_VERSION = 1;
@@ -62,6 +63,7 @@ export class DungeonRuleError extends Error {
62
63
  export function createDungeonState(
63
64
  ownerId: string,
64
65
  ownerName: string,
66
+ locale?: Locale,
65
67
  ): DungeonState {
66
68
  return {
67
69
  schemaVersion: DUNGEON_SCHEMA_VERSION,
@@ -74,7 +76,7 @@ export function createDungeonState(
74
76
  turnIndex: 0,
75
77
  players: [createPlayer(ownerId, ownerName, true)],
76
78
  enemy: null,
77
- log: [`${ownerName} 创建了远征队。`],
79
+ log: [messages(locale, 'text1', { p0: ownerName })],
78
80
  };
79
81
  }
80
82
 
@@ -83,41 +85,42 @@ export function applyDungeonAction(
83
85
  actorId: string,
84
86
  action: DungeonAction,
85
87
  random: DeterministicRandom,
88
+ locale?: Locale,
86
89
  ): DungeonState {
87
90
  if (state.phase === 'completed') {
88
- throw new DungeonRuleError('本次远征已经结束。');
91
+ throw new DungeonRuleError(messages(locale, 'text2'));
89
92
  }
90
- if (action.type === 'join') return joinParty(state, actorId, action.name);
93
+ if (action.type === 'join') return joinParty(state, actorId, action.name, locale);
91
94
  const actorIndex = state.players.findIndex((player) => player.id === actorId);
92
- if (actorIndex < 0) throw new DungeonRuleError('你还没有加入这支远征队。');
95
+ if (actorIndex < 0) throw new DungeonRuleError(messages(locale, 'text3'));
93
96
  if (action.type === 'abort') {
94
97
  if (state.ownerId !== actorId) {
95
- throw new DungeonRuleError('只有队长可以结束整支远征。');
98
+ throw new DungeonRuleError(messages(locale, 'text4'));
96
99
  }
97
100
  return appendLog({
98
101
  ...state,
99
102
  phase: 'completed',
100
103
  result: 'aborted',
101
104
  enemy: null,
102
- }, '队长结束了本次远征。');
105
+ }, messages(locale, 'text5'));
103
106
  }
104
107
  if (action.type === 'ready') {
105
108
  if (state.phase !== 'lobby') {
106
- throw new DungeonRuleError('远征已经开始,无法修改准备状态。');
109
+ throw new DungeonRuleError(messages(locale, 'text6'));
107
110
  }
108
111
  const players = state.players.map((player, index) =>
109
112
  index === actorIndex ? { ...player, ready: !player.ready } : player);
110
113
  return appendLog(
111
114
  { ...state, players },
112
- `${players[actorIndex]?.name ?? actorId}${players[actorIndex]?.ready ? '已准备' : '取消准备'}。`,
115
+ `${players[actorIndex]?.name ?? actorId}${players[actorIndex]?.ready ? messages(locale, 'text7') : messages(locale, 'text8')}。`,
113
116
  );
114
117
  }
115
- if (action.type === 'start') return startExpedition(state, actorId);
116
- assertActorTurn(state, actorId);
117
- if (action.type === 'explore') return explore(state, random);
118
- if (action.type === 'attack') return attack(state, actorIndex, random);
119
- if (action.type === 'defend') return defend(state, actorIndex, random);
120
- return drinkPotion(state, actorIndex, random);
118
+ if (action.type === 'start') return startExpedition(state, actorId, locale);
119
+ assertActorTurn(state, actorId, locale);
120
+ if (action.type === 'explore') return explore(state, random, locale);
121
+ if (action.type === 'attack') return attack(state, actorIndex, random, locale);
122
+ if (action.type === 'defend') return defend(state, actorIndex, random, locale);
123
+ return drinkPotion(state, actorIndex, random, locale);
121
124
  }
122
125
 
123
126
  export function activePlayer(state: Readonly<DungeonState>): DungeonPlayer | null {
@@ -156,73 +159,76 @@ function joinParty(
156
159
  state: Readonly<DungeonState>,
157
160
  actorId: string,
158
161
  actorName: string,
162
+ locale?: Locale,
159
163
  ): DungeonState {
160
164
  if (state.phase !== 'lobby') {
161
- throw new DungeonRuleError('远征已经出发,无法中途加入。');
165
+ throw new DungeonRuleError(messages(locale, 'text9'));
162
166
  }
163
167
  if (state.players.some((player) => player.id === actorId)) {
164
- throw new DungeonRuleError('你已经在远征队中。');
168
+ throw new DungeonRuleError(messages(locale, 'text10'));
165
169
  }
166
170
  if (state.players.length >= MAX_PARTY_SIZE) {
167
- throw new DungeonRuleError('远征队已满,最多 4 人。');
171
+ throw new DungeonRuleError(messages(locale, 'text11'));
168
172
  }
169
173
  const player = createPlayer(actorId, actorName, false);
170
174
  return appendLog(
171
175
  { ...state, players: [...state.players, player] },
172
- `${player.name} 加入了远征队。`,
176
+ messages(locale, 'text12', { p0: player.name }),
173
177
  );
174
178
  }
175
179
 
176
180
  function startExpedition(
177
181
  state: Readonly<DungeonState>,
178
182
  actorId: string,
183
+ locale?: Locale,
179
184
  ): DungeonState {
180
185
  if (state.phase !== 'lobby') {
181
- throw new DungeonRuleError('远征已经开始。');
186
+ throw new DungeonRuleError(messages(locale, 'text13'));
182
187
  }
183
188
  if (state.ownerId !== actorId) {
184
- throw new DungeonRuleError('只有队长可以带队出发。');
189
+ throw new DungeonRuleError(messages(locale, 'text14'));
185
190
  }
186
191
  const waiting = state.players.filter((player) => !player.ready);
187
192
  if (waiting.length > 0) {
188
193
  throw new DungeonRuleError(
189
- `仍有队员未准备:${waiting.map((player) => player.name).join('、')}`,
194
+ messages(locale, 'text15', { p0: waiting.map((player) => player.name).join('、') }),
190
195
  );
191
196
  }
192
- return appendLog({ ...state, phase: 'exploring' }, '远征队进入了第一层地牢。');
197
+ return appendLog({ ...state, phase: 'exploring' }, messages(locale, 'text16'));
193
198
  }
194
199
 
195
- function assertActorTurn(state: Readonly<DungeonState>, actorId: string): void {
200
+ function assertActorTurn(state: Readonly<DungeonState>, actorId: string, locale?: Locale): void {
196
201
  if (state.phase === 'lobby') {
197
- throw new DungeonRuleError('请先由队长开始远征。');
202
+ throw new DungeonRuleError(messages(locale, 'text17'));
198
203
  }
199
204
  const current = activePlayer(state);
200
205
  if (!current || current.id !== actorId) {
201
- throw new DungeonRuleError(`现在轮到 ${current?.name ?? '其他队员'} 行动。`);
206
+ throw new DungeonRuleError(messages(locale, 'text18', { p0: current?.name ?? messages(locale, 'otherPlayer') }));
202
207
  }
203
208
  }
204
209
 
205
210
  function explore(
206
211
  state: Readonly<DungeonState>,
207
212
  random: DeterministicRandom,
213
+ locale?: Locale,
208
214
  ): DungeonState {
209
215
  if (state.phase !== 'exploring') {
210
- throw new DungeonRuleError('战斗尚未结束,无法继续探索。');
216
+ throw new DungeonRuleError(messages(locale, 'text19'));
211
217
  }
212
218
  const room = state.room + 1;
213
219
  if (room >= BOSS_ROOM) {
214
- const enemy = createEnemy(state.floor, true, random);
220
+ const enemy = createEnemy(state.floor, true, random, locale);
215
221
  return appendLog(
216
222
  { ...state, room, phase: 'combat', enemy },
217
- `守层者 ${enemy.name} 挡住了去路!`,
223
+ messages(locale, 'text20', { p0: enemy.name }),
218
224
  );
219
225
  }
220
226
  const roll = random.int(100);
221
227
  if (roll < 58) {
222
- const enemy = createEnemy(state.floor, false, random);
228
+ const enemy = createEnemy(state.floor, false, random, locale);
223
229
  return appendLog(
224
230
  { ...state, room, phase: 'combat', enemy },
225
- `遭遇了 ${enemy.name}!`,
231
+ messages(locale, 'text21', { p0: enemy.name }),
226
232
  );
227
233
  }
228
234
  if (roll < 78) {
@@ -236,7 +242,7 @@ function explore(
236
242
  gold: player.gold + gold,
237
243
  })),
238
244
  },
239
- `发现宝箱,每位队员获得 ${gold} 金币。`,
245
+ messages(locale, 'text22', { p0: gold }),
240
246
  );
241
247
  }
242
248
  if (roll < 90) {
@@ -249,7 +255,7 @@ function explore(
249
255
  hp: Math.min(player.maxHp, player.hp + 18),
250
256
  })),
251
257
  },
252
- '发现治愈泉,队伍恢复了生命。',
258
+ messages(locale, 'text23'),
253
259
  );
254
260
  }
255
261
  const damage = random.intInclusive(8, 16);
@@ -261,9 +267,9 @@ function explore(
261
267
  : player);
262
268
  const trapped = advanceAfterRoom(
263
269
  { ...state, room, players },
264
- `${target?.name ?? '队员'} 触发陷阱,受到 ${damage} 点伤害。`,
270
+ messages(locale, 'text24', { p0: target?.name ?? messages(locale, 'player'), p1: damage }),
265
271
  );
266
- const resolved = finishIfDefeated(trapped);
272
+ const resolved = finishIfDefeated(trapped, locale);
267
273
  if (resolved.phase === 'completed' || (players[targetIndex]?.hp ?? 0) > 0) {
268
274
  return resolved;
269
275
  }
@@ -274,49 +280,52 @@ function attack(
274
280
  state: Readonly<DungeonState>,
275
281
  actorIndex: number,
276
282
  random: DeterministicRandom,
283
+ locale?: Locale,
277
284
  ): DungeonState {
278
285
  if (state.phase !== 'combat' || !state.enemy) {
279
- throw new DungeonRuleError('当前没有可以攻击的敌人。');
286
+ throw new DungeonRuleError(messages(locale, 'text25'));
280
287
  }
281
288
  const actor = state.players[actorIndex];
282
- if (!actor || actor.hp <= 0) throw new DungeonRuleError('倒下的队员无法行动。');
289
+ if (!actor || actor.hp <= 0) throw new DungeonRuleError(messages(locale, 'text26'));
283
290
  const damage = random.intInclusive(12, 22);
284
291
  const enemy = { ...state.enemy, hp: Math.max(0, state.enemy.hp - damage) };
285
292
  let next = appendLog(
286
293
  { ...state, enemy },
287
- `${actor.name} 对 ${enemy.name} 造成 ${damage} 点伤害。`,
294
+ messages(locale, 'text27', { p0: actor.name, p1: enemy.name, p2: damage }),
288
295
  );
289
- if (enemy.hp <= 0) return defeatEnemy(next, random);
290
- next = enemyTurn(next, actorIndex, random);
291
- return advanceTurn(finishIfDefeated(next));
296
+ if (enemy.hp <= 0) return defeatEnemy(next, random, locale);
297
+ next = enemyTurn(next, actorIndex, random, locale);
298
+ return advanceTurn(finishIfDefeated(next, locale));
292
299
  }
293
300
 
294
301
  function defend(
295
302
  state: Readonly<DungeonState>,
296
303
  actorIndex: number,
297
304
  random: DeterministicRandom,
305
+ locale?: Locale,
298
306
  ): DungeonState {
299
307
  if (state.phase !== 'combat' || !state.enemy) {
300
- throw new DungeonRuleError('当前不需要防御。');
308
+ throw new DungeonRuleError(messages(locale, 'text28'));
301
309
  }
302
310
  const actor = state.players[actorIndex];
303
- if (!actor || actor.hp <= 0) throw new DungeonRuleError('倒下的队员无法行动。');
311
+ if (!actor || actor.hp <= 0) throw new DungeonRuleError(messages(locale, 'text26'));
304
312
  const players = state.players.map((player, index) =>
305
313
  index === actorIndex ? { ...player, guard: 8 } : player);
306
- let next = appendLog({ ...state, players }, `${actor.name} 架起了盾牌。`);
307
- next = enemyTurn(next, actorIndex, random);
308
- return advanceTurn(finishIfDefeated(next));
314
+ let next = appendLog({ ...state, players }, messages(locale, 'text29', { p0: actor.name }));
315
+ next = enemyTurn(next, actorIndex, random, locale);
316
+ return advanceTurn(finishIfDefeated(next, locale));
309
317
  }
310
318
 
311
319
  function drinkPotion(
312
320
  state: Readonly<DungeonState>,
313
321
  actorIndex: number,
314
322
  random: DeterministicRandom,
323
+ locale?: Locale,
315
324
  ): DungeonState {
316
325
  const actor = state.players[actorIndex];
317
- if (!actor || actor.hp <= 0) throw new DungeonRuleError('倒下的队员无法行动。');
318
- if (actor.potions <= 0) throw new DungeonRuleError('你的药水已经用完了。');
319
- if (actor.hp >= actor.maxHp) throw new DungeonRuleError('你的生命值已经满了。');
326
+ if (!actor || actor.hp <= 0) throw new DungeonRuleError(messages(locale, 'text26'));
327
+ if (actor.potions <= 0) throw new DungeonRuleError(messages(locale, 'text30'));
328
+ if (actor.hp >= actor.maxHp) throw new DungeonRuleError(messages(locale, 'text31'));
320
329
  const players = state.players.map((player, index) =>
321
330
  index === actorIndex
322
331
  ? {
@@ -325,10 +334,10 @@ function drinkPotion(
325
334
  potions: player.potions - 1,
326
335
  }
327
336
  : player);
328
- let next = appendLog({ ...state, players }, `${actor.name} 使用药水恢复生命。`);
337
+ let next = appendLog({ ...state, players }, messages(locale, 'text32', { p0: actor.name }));
329
338
  if (state.phase === 'combat') {
330
- next = enemyTurn(next, actorIndex, random);
331
- return advanceTurn(finishIfDefeated(next));
339
+ next = enemyTurn(next, actorIndex, random, locale);
340
+ return advanceTurn(finishIfDefeated(next, locale));
332
341
  }
333
342
  return next;
334
343
  }
@@ -337,6 +346,7 @@ function enemyTurn(
337
346
  state: Readonly<DungeonState>,
338
347
  targetIndex: number,
339
348
  random: DeterministicRandom,
349
+ locale?: Locale,
340
350
  ): DungeonState {
341
351
  const enemy = state.enemy;
342
352
  const target = state.players[targetIndex];
@@ -352,13 +362,14 @@ function enemyTurn(
352
362
  : player);
353
363
  return appendLog(
354
364
  { ...state, players },
355
- `${enemy.name} 反击 ${target.name},造成 ${damage} 点伤害。`,
365
+ messages(locale, 'text33', { p0: enemy.name, p1: target.name, p2: damage }),
356
366
  );
357
367
  }
358
368
 
359
369
  function defeatEnemy(
360
370
  state: Readonly<DungeonState>,
361
371
  random: DeterministicRandom,
372
+ locale?: Locale,
362
373
  ): DungeonState {
363
374
  const enemy = state.enemy;
364
375
  if (!enemy) return state;
@@ -377,7 +388,7 @@ function defeatEnemy(
377
388
  phase: 'completed',
378
389
  result: 'victory',
379
390
  roomsCleared: state.roomsCleared + 1,
380
- }, `击败最终守层者,每人获得 ${gold} 金币。远征胜利!`);
391
+ }, messages(locale, 'text34', { p0: gold }));
381
392
  }
382
393
  if (enemy.boss) {
383
394
  return appendLog({
@@ -386,11 +397,11 @@ function defeatEnemy(
386
397
  floor: state.floor + 1,
387
398
  room: 0,
388
399
  roomsCleared: state.roomsCleared + 1,
389
- }, `击败守层者,每人获得 ${gold} 金币。队伍进入下一层。`);
400
+ }, messages(locale, 'text35', { p0: gold }));
390
401
  }
391
402
  return advanceAfterRoom(
392
403
  { ...rewarded, phase: 'exploring' },
393
- `击败 ${enemy.name},每人获得 ${gold} 金币。`,
404
+ messages(locale, 'text36', { p0: enemy.name, p1: gold }),
394
405
  );
395
406
  }
396
407
 
@@ -398,10 +409,11 @@ function createEnemy(
398
409
  floor: number,
399
410
  boss: boolean,
400
411
  random: DeterministicRandom,
412
+ locale?: Locale,
401
413
  ): DungeonEnemy {
402
414
  const names = boss
403
- ? ['石像守卫', '深渊术士', '远古龙影']
404
- : ['洞穴史莱姆', '骷髅巡卫', '暗影猎手', '失控魔像'];
415
+ ? [messages(locale, 'text37'), messages(locale, 'text38'), messages(locale, 'text39')]
416
+ : [messages(locale, 'text40'), messages(locale, 'text41'), messages(locale, 'text42'), messages(locale, 'text43')];
405
417
  const name = boss
406
418
  ? names[Math.min(names.length - 1, floor - 1)] as string
407
419
  : random.item(names);
@@ -445,14 +457,14 @@ function nextLivingIndex(
445
457
  return 0;
446
458
  }
447
459
 
448
- function finishIfDefeated(state: Readonly<DungeonState>): DungeonState {
460
+ function finishIfDefeated(state: Readonly<DungeonState>, locale?: Locale): DungeonState {
449
461
  if (state.players.some((player) => player.hp > 0)) return state as DungeonState;
450
462
  return appendLog({
451
463
  ...state,
452
464
  phase: 'completed',
453
465
  result: 'defeat',
454
466
  enemy: null,
455
- }, '远征队全员倒下,本次远征失败。');
467
+ }, messages(locale, 'text44'));
456
468
  }
457
469
 
458
470
  function appendLog(
@@ -464,3 +476,52 @@ function appendLog(
464
476
  log: [...state.log, message].slice(-8),
465
477
  } as DungeonState;
466
478
  }
479
+
480
+ const messages = createMessages({
481
+ otherPlayer: { 'zh-CN': '其他队员', en: 'another player' },
482
+ player: { 'zh-CN': '队员', en: 'A player' },
483
+ text1: { 'zh-CN': "{p0} 创建了远征队。", en: "{p0} created the expedition party." },
484
+ text2: { 'zh-CN': "本次远征已经结束。", en: "This expedition has ended." },
485
+ text3: { 'zh-CN': "你还没有加入这支远征队。", en: "You have not joined this expedition." },
486
+ text4: { 'zh-CN': "只有队长可以结束整支远征。", en: "Only the leader may end the expedition." },
487
+ text5: { 'zh-CN': "队长结束了本次远征。", en: "The leader ended the expedition." },
488
+ text6: { 'zh-CN': "远征已经开始,无法修改准备状态。", en: "The expedition has started; readiness can no longer be changed." },
489
+ text7: { 'zh-CN': "已准备", en: " is ready" },
490
+ text8: { 'zh-CN': "取消准备", en: " is no longer ready" },
491
+ text9: { 'zh-CN': "远征已经出发,无法中途加入。", en: "The expedition has started; you cannot join mid-game." },
492
+ text10: { 'zh-CN': "你已经在远征队中。", en: "You are already in the party." },
493
+ text11: { 'zh-CN': "远征队已满,最多 4 人。", en: "The party is full (maximum four players)." },
494
+ text12: { 'zh-CN': "{p0} 加入了远征队。", en: "{p0} joined the party." },
495
+ text13: { 'zh-CN': "远征已经开始。", en: "The expedition has already started." },
496
+ text14: { 'zh-CN': "只有队长可以带队出发。", en: "Only the leader may start the expedition." },
497
+ text15: { 'zh-CN': "仍有队员未准备:{p0}", en: "Players not ready: {p0}" },
498
+ text16: { 'zh-CN': "远征队进入了第一层地牢。", en: "The party entered the first floor." },
499
+ text17: { 'zh-CN': "请先由队长开始远征。", en: "Ask the leader to start the expedition first." },
500
+ text18: { 'zh-CN': "现在轮到 {p0} 行动。", en: "It is {p0}’s turn." },
501
+ text19: { 'zh-CN': "战斗尚未结束,无法继续探索。", en: "Finish the battle before exploring further." },
502
+ text20: { 'zh-CN': "守层者 {p0} 挡住了去路!", en: "Floor guardian {p0} blocks the way!" },
503
+ text21: { 'zh-CN': "遭遇了 {p0}!", en: "Encountered {p0}!" },
504
+ text22: { 'zh-CN': "发现宝箱,每位队员获得 {p0} 金币。", en: "A treasure chest! Each player gains {p0} gold." },
505
+ text23: { 'zh-CN': "发现治愈泉,队伍恢复了生命。", en: "A healing spring restores the party’s health." },
506
+ text24: { 'zh-CN': "{p0} 触发陷阱,受到 {p1} 点伤害。", en: "{p0} triggered a trap and took {p1} damage." },
507
+ text25: { 'zh-CN': "当前没有可以攻击的敌人。", en: "There is no enemy to attack." },
508
+ text26: { 'zh-CN': "倒下的队员无法行动。", en: "Defeated players cannot act." },
509
+ text27: { 'zh-CN': "{p0} 对 {p1} 造成 {p2} 点伤害。", en: "{p0} dealt {p2} damage to {p1}." },
510
+ text28: { 'zh-CN': "当前不需要防御。", en: "There is no need to defend right now." },
511
+ text29: { 'zh-CN': "{p0} 架起了盾牌。", en: "{p0} raised a shield." },
512
+ text30: { 'zh-CN': "你的药水已经用完了。", en: "You have no potions left." },
513
+ text31: { 'zh-CN': "你的生命值已经满了。", en: "Your health is already full." },
514
+ text32: { 'zh-CN': "{p0} 使用药水恢复生命。", en: "{p0} used a potion to restore health." },
515
+ text33: { 'zh-CN': "{p0} 反击 {p1},造成 {p2} 点伤害。", en: "{p0} counterattacked {p1} for {p2} damage." },
516
+ text34: { 'zh-CN': "击败最终守层者,每人获得 {p0} 金币。远征胜利!", en: "The final guardian is defeated! Each player gains {p0} gold. Expedition victory!" },
517
+ text35: { 'zh-CN': "击败守层者,每人获得 {p0} 金币。队伍进入下一层。", en: "The floor guardian is defeated! Each player gains {p0} gold. The party advances to the next floor." },
518
+ text36: { 'zh-CN': "击败 {p0},每人获得 {p1} 金币。", en: "Defeated {p0}! Each player gains {p1} gold." },
519
+ text37: { 'zh-CN': "石像守卫", en: "Stone Guardian" },
520
+ text38: { 'zh-CN': "深渊术士", en: "Abyss Warlock" },
521
+ text39: { 'zh-CN': "远古龙影", en: "Ancient Dragon Shade" },
522
+ text40: { 'zh-CN': "洞穴史莱姆", en: "Cave Slime" },
523
+ text41: { 'zh-CN': "骷髅巡卫", en: "Skeleton Patrol" },
524
+ text42: { 'zh-CN': "暗影猎手", en: "Shadow Hunter" },
525
+ text43: { 'zh-CN': "失控魔像", en: "Rampaging Golem" },
526
+ text44: { 'zh-CN': "远征队全员倒下,本次远征失败。", en: "The entire party fell. The expedition failed." },
527
+ });
package/src/game-flow.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createMessages, resolveLocale } from 'zhin.js/i18n';
1
2
  import {
2
3
  GameSessionConflictError,
3
4
  SessionRevisionConflictError,
@@ -29,7 +30,7 @@ export async function startDungeon(
29
30
  const session = await service.createSession(message);
30
31
  return buildDungeonView(session, stateFromSession(session), message.$channel.type);
31
32
  } catch (error) {
32
- if (error instanceof GameSessionConflictError) return error.message;
33
+ if (error instanceof GameSessionConflictError) return error.format(message.locale);
33
34
  throw error;
34
35
  }
35
36
  }
@@ -38,8 +39,9 @@ export async function continueDungeon(
38
39
  service: SessionService,
39
40
  message: GameMessageLike,
40
41
  ): Promise<GameReply> {
42
+ const locale = resolveLocale(message.locale);
41
43
  const session = await service.getActiveByChannel(channelKey(message));
42
- if (!session) return '当前频道没有进行中的地牢远征。发送「地牢 开始」创建队伍。';
44
+ if (!session) return messages(locale, 'text1');
43
45
  return buildDungeonView(session, stateFromSession(session), message.$channel.type);
44
46
  }
45
47
 
@@ -49,19 +51,21 @@ export async function handleDungeonChoice(
49
51
  sessionToken: string,
50
52
  choiceId: string,
51
53
  ): Promise<GameReply> {
54
+ let locale = resolveLocale(message.locale);
52
55
  const token = parseDungeonSessionToken(sessionToken);
53
- if (!token) return '无效的地牢操作。';
56
+ if (!token) return messages(locale, 'text2');
54
57
  const session = await service.getById(token.sessionId);
55
58
  if (!session || session.channel_key !== channelKey(message)) {
56
- return '远征不存在,或不属于当前频道。';
59
+ return messages(locale, 'text3');
57
60
  }
61
+ locale = resolveLocale(session.locale);
58
62
  if (choiceId === 'restart') {
59
- if (session.status === 'active') return '当前远征尚未结束。';
60
- if (session.owner_id !== message.$sender.id) return '只有原队长可以重新创建远征。';
63
+ if (session.status === 'active') return messages(locale, 'text4');
64
+ if (session.owner_id !== message.$sender.id) return messages(locale, 'text5');
61
65
  return startDungeon(service, message);
62
66
  }
63
67
  const action = actionFromChoice(choiceId, message);
64
- if (!action) return '未知的地牢操作。';
68
+ if (!action) return messages(locale, 'text6');
65
69
  try {
66
70
  const result = await service.performAction({
67
71
  sessionId: session.id,
@@ -77,21 +81,19 @@ export async function handleDungeonChoice(
77
81
  message.$channel.type,
78
82
  );
79
83
  } catch (error) {
80
- if (error instanceof DungeonRuleError
81
- || error instanceof GameSessionConflictError) {
82
- return error.message;
83
- }
84
+ if (error instanceof DungeonRuleError) return error.message;
85
+ if (error instanceof GameSessionConflictError) return error.format(locale);
84
86
  if (error instanceof SessionRevisionConflictError) {
85
87
  const latest = await service.getById(session.id);
86
- if (!latest) return '远征状态已变化,请重新打开当前界面。';
88
+ if (!latest) return messages(locale, 'text7');
87
89
  const view = buildDungeonView(
88
90
  latest,
89
91
  stateFromSession(latest),
90
92
  message.$channel.type,
91
93
  );
92
94
  return Array.isArray(view)
93
- ? ['操作来自旧回合,已为你刷新最新状态。', ...view]
94
- : ['操作来自旧回合,已为你刷新最新状态。', view];
95
+ ? [messages(locale, 'text8'), ...view]
96
+ : [messages(locale, 'text8'), view];
95
97
  }
96
98
  throw error;
97
99
  }
@@ -124,3 +126,14 @@ function actionFromChoice(
124
126
  if (choiceId === 'abort') return { type: 'abort' };
125
127
  return null;
126
128
  }
129
+
130
+ const messages = createMessages({
131
+ text1: { 'zh-CN': "当前频道没有进行中的地牢远征。", en: "There is no active dungeon expedition in this channel." },
132
+ text2: { 'zh-CN': "无效的地牢操作。", en: "Invalid dungeon action." },
133
+ text3: { 'zh-CN': "远征不存在,或不属于当前频道。", en: "The expedition does not exist or belongs to another channel." },
134
+ text4: { 'zh-CN': "当前远征尚未结束。", en: "The current expedition has not ended." },
135
+ text5: { 'zh-CN': "只有原队长可以重新创建远征。", en: "Only the original leader may restart the expedition." },
136
+ text6: { 'zh-CN': "未知的地牢操作。", en: "Unknown dungeon action." },
137
+ text7: { 'zh-CN': "远征状态已变化,请重新打开当前界面。", en: "The expedition state changed. Reopen its current view." },
138
+ text8: { 'zh-CN': "操作来自旧回合,已为你刷新最新状态。", en: "This action was from an earlier turn. The latest state is shown below." },
139
+ });
package/src/models.ts CHANGED
@@ -6,6 +6,7 @@ declare module '@zhin.js/core' {
6
6
  interface Models {
7
7
  dungeon_sessions: {
8
8
  id: string;
9
+ locale?: 'zh-CN' | 'en' | null;
9
10
  adapter: string;
10
11
  endpoint: string;
11
12
  channel_type: string;
@@ -32,11 +33,12 @@ export type DungeonSessionRow = Models['dungeon_sessions'];
32
33
 
33
34
  export function defineHostTables(
34
35
  database: {
35
- define(name: string, definition: Record<string, unknown>): void;
36
+ define(name: string, definition: Record<string, unknown>, options?: { readonly addColumns?: readonly string[] }): void;
36
37
  },
37
38
  ): void {
38
39
  database.define('dungeon_sessions', {
39
40
  id: { type: 'text', primary: true },
41
+ locale: { type: 'text', nullable: true },
40
42
  adapter: { type: 'text', nullable: false },
41
43
  endpoint: { type: 'text', nullable: false },
42
44
  channel_type: { type: 'text', nullable: false },
@@ -55,5 +57,5 @@ export function defineHostTables(
55
57
  status: { type: 'text', default: 'active' },
56
58
  updated_at: { type: 'bigint', default: 0 },
57
59
  created_at: { type: 'bigint', default: 0 },
58
- });
60
+ }, { addColumns: ['locale'] });
59
61
  }
@@ -47,10 +47,11 @@ export class SessionService extends VersionedSessionService<DungeonSessionRow> {
47
47
  const now = Date.now();
48
48
  const id = generateCompactId('dng_');
49
49
  const ownerName = message.$sender.name?.trim() || message.$sender.id;
50
- const state = createDungeonState(message.$sender.id, ownerName);
50
+ const state = createDungeonState(message.$sender.id, ownerName, message.locale);
51
51
  const random = DeterministicRandom.fromSeed(seed ?? id);
52
52
  return this.createRow({
53
53
  id,
54
+ locale: message.locale ?? 'zh-CN',
54
55
  adapter: String(message.$adapter),
55
56
  endpoint: message.$endpoint,
56
57
  channel_type: message.$channel.type,
@@ -113,6 +114,7 @@ export class SessionService extends VersionedSessionService<DungeonSessionRow> {
113
114
  options.actorId,
114
115
  options.action,
115
116
  random,
117
+ session.locale ?? undefined,
116
118
  );
117
119
  return {
118
120
  state_json: JSON.stringify(next),