@zhin.js/plugin-dungeon-expedition 1.0.0
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/README.md +63 -0
- package/commands/dungeon/[action:string=].js +15 -0
- package/commands/dungeon/[action:string=].ts +22 -0
- package/lib/dungeon-command.d.ts +5 -0
- package/lib/dungeon-command.js +62 -0
- package/lib/dungeon-command.js.map +1 -0
- package/lib/engine.d.ts +63 -0
- package/lib/engine.js +325 -0
- package/lib/engine.js.map +1 -0
- package/lib/game-flow.d.ts +7 -0
- package/lib/game-flow.js +97 -0
- package/lib/game-flow.js.map +1 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.js +6 -0
- package/lib/index.js.map +1 -0
- package/lib/models.d.ts +31 -0
- package/lib/models.js +24 -0
- package/lib/models.js.map +1 -0
- package/lib/runtime-store.d.ts +5 -0
- package/lib/runtime-store.js +6 -0
- package/lib/runtime-store.js.map +1 -0
- package/lib/session-service.d.ts +21 -0
- package/lib/session-service.js +99 -0
- package/lib/session-service.js.map +1 -0
- package/lib/view.d.ts +12 -0
- package/lib/view.js +94 -0
- package/lib/view.js.map +1 -0
- package/middlewares/dungeon-alias.js +12 -0
- package/middlewares/dungeon-alias.ts +21 -0
- package/middlewares/dungeon-choice.js +45 -0
- package/middlewares/dungeon-choice.ts +71 -0
- package/package.json +72 -0
- package/plugin.js +35 -0
- package/schema.json +6 -0
- package/src/dungeon-command.ts +80 -0
- package/src/engine.ts +466 -0
- package/src/game-flow.ts +126 -0
- package/src/index.ts +28 -0
- package/src/models.ts +59 -0
- package/src/runtime-store.ts +12 -0
- package/src/session-service.ts +157 -0
- package/src/view.ts +113 -0
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhin.js/plugin-dungeon-expedition",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zhin.js multiplayer deterministic dungeon expedition",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"types": "./lib/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./lib/index.d.ts",
|
|
11
|
+
"development": "./src/index.ts",
|
|
12
|
+
"import": "./lib/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"plugin.js",
|
|
18
|
+
"schema.json",
|
|
19
|
+
"commands",
|
|
20
|
+
"middlewares",
|
|
21
|
+
"src",
|
|
22
|
+
"lib",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@zhin.js/command": "1.0.3",
|
|
28
|
+
"@zhin.js/middleware": "1.0.3",
|
|
29
|
+
"@zhin.js/game-kit": "2.0.1",
|
|
30
|
+
"@zhin.js/plugin-runtime": "1.1.1",
|
|
31
|
+
"@zhin.js/core": "1.4.1"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"typescript": "^6.0.3",
|
|
35
|
+
"vitest": "^4.1.10"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/zhinjs/zhin.git",
|
|
40
|
+
"directory": "plugins/games/dungeon-expedition"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public",
|
|
44
|
+
"registry": "https://registry.npmjs.org"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
48
|
+
},
|
|
49
|
+
"zhin": {
|
|
50
|
+
"protocol": 1,
|
|
51
|
+
"type": "plugin",
|
|
52
|
+
"entry": "./plugin.js",
|
|
53
|
+
"engine": "^1.0.0",
|
|
54
|
+
"runtime": "trusted",
|
|
55
|
+
"features": [
|
|
56
|
+
{
|
|
57
|
+
"package": "@zhin.js/command",
|
|
58
|
+
"api": "^1.0.0"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"package": "@zhin.js/middleware",
|
|
62
|
+
"api": "^1.0.0"
|
|
63
|
+
}
|
|
64
|
+
],
|
|
65
|
+
"plugins": []
|
|
66
|
+
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"build": "tsc",
|
|
69
|
+
"clean": "rimraf lib",
|
|
70
|
+
"test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/games/dungeon-expedition/tests"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/plugin.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Generated by build-plugin-runtime-entries.mjs. Do not edit.
|
|
2
|
+
import { defineGamePlugin, } from '@zhin.js/game-kit';
|
|
3
|
+
import { defineHostTables } from "./lib/models.js";
|
|
4
|
+
import { gameServicesToken } from "./lib/runtime-store.js";
|
|
5
|
+
import { createServices, } from "./lib/session-service.js";
|
|
6
|
+
export default defineGamePlugin({
|
|
7
|
+
name: 'dungeon-expedition',
|
|
8
|
+
metadata: {
|
|
9
|
+
displayName: 'Dungeon Expedition',
|
|
10
|
+
},
|
|
11
|
+
game: {
|
|
12
|
+
id: 'dungeon',
|
|
13
|
+
title: '地牢远征',
|
|
14
|
+
icon: '⚔️',
|
|
15
|
+
description: '1-4 人组队,三层地牢,确定性回合冒险',
|
|
16
|
+
commandPrefix: '/地牢',
|
|
17
|
+
quickStart: '开始',
|
|
18
|
+
aliases: ['dungeon'],
|
|
19
|
+
menus: [
|
|
20
|
+
{ id: 'create', label: '创建远征队' },
|
|
21
|
+
{ id: 'join', label: '加入队伍' },
|
|
22
|
+
{ id: 'status', label: '当前状态' },
|
|
23
|
+
{ id: 'help', label: '玩法说明' },
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
tables: ['dungeon_sessions'],
|
|
27
|
+
servicesToken: gameServicesToken,
|
|
28
|
+
defineHostTables,
|
|
29
|
+
createServices: (database) => createServices(database),
|
|
30
|
+
session: (service) => service,
|
|
31
|
+
stale: {
|
|
32
|
+
idleMs: 30 * 60_000,
|
|
33
|
+
message: '地牢远征因 30 分钟无人操作,已自动结束。',
|
|
34
|
+
},
|
|
35
|
+
});
|
package/schema.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {
|
|
2
|
+
channelKey,
|
|
3
|
+
type GameMessageLike,
|
|
4
|
+
type GameReply,
|
|
5
|
+
} from '@zhin.js/game-kit';
|
|
6
|
+
import {
|
|
7
|
+
continueDungeon,
|
|
8
|
+
handleDungeonChoice,
|
|
9
|
+
startDungeon,
|
|
10
|
+
} from './game-flow.js';
|
|
11
|
+
import type { SessionService } from './session-service.js';
|
|
12
|
+
import { dungeonSessionToken } from './view.js';
|
|
13
|
+
|
|
14
|
+
export const DUNGEON_HELP = [
|
|
15
|
+
'地牢远征 · 1-4 人确定性回合冒险',
|
|
16
|
+
'地牢 开始 — 创建远征队',
|
|
17
|
+
'地牢 加入 — 加入当前频道的队伍',
|
|
18
|
+
'地牢 准备 — 切换准备状态',
|
|
19
|
+
'地牢 出发 — 队长开始远征',
|
|
20
|
+
'地牢 探索 — 探索下一房间',
|
|
21
|
+
'地牢 攻击 / 防御 / 药水 — 战斗操作',
|
|
22
|
+
'地牢 状态 — 刷新当前界面',
|
|
23
|
+
'地牢 结束 — 队长结束远征',
|
|
24
|
+
].join('\n');
|
|
25
|
+
|
|
26
|
+
const ACTION_ALIASES: Readonly<Record<string, string>> = {
|
|
27
|
+
'': 'status',
|
|
28
|
+
help: 'help',
|
|
29
|
+
帮助: 'help',
|
|
30
|
+
start: 'create',
|
|
31
|
+
开始: 'create',
|
|
32
|
+
create: 'create',
|
|
33
|
+
创建: 'create',
|
|
34
|
+
status: 'status',
|
|
35
|
+
状态: 'status',
|
|
36
|
+
continue: 'status',
|
|
37
|
+
继续: 'status',
|
|
38
|
+
join: 'join',
|
|
39
|
+
加入: 'join',
|
|
40
|
+
ready: 'ready',
|
|
41
|
+
准备: 'ready',
|
|
42
|
+
go: 'start',
|
|
43
|
+
出发: 'start',
|
|
44
|
+
explore: 'explore',
|
|
45
|
+
探索: 'explore',
|
|
46
|
+
attack: 'attack',
|
|
47
|
+
攻击: 'attack',
|
|
48
|
+
defend: 'defend',
|
|
49
|
+
防御: 'defend',
|
|
50
|
+
potion: 'potion',
|
|
51
|
+
药水: 'potion',
|
|
52
|
+
quit: 'abort',
|
|
53
|
+
abort: 'abort',
|
|
54
|
+
结束: 'abort',
|
|
55
|
+
放弃: 'abort',
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export function normalizeDungeonAction(action: string): string | null {
|
|
59
|
+
return ACTION_ALIASES[action.trim().toLowerCase()] ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function runDungeonCommand(
|
|
63
|
+
service: SessionService,
|
|
64
|
+
message: GameMessageLike,
|
|
65
|
+
rawAction: string,
|
|
66
|
+
): Promise<GameReply> {
|
|
67
|
+
const action = normalizeDungeonAction(rawAction);
|
|
68
|
+
if (!action || action === 'help') return DUNGEON_HELP;
|
|
69
|
+
if (action === 'create') return startDungeon(service, message);
|
|
70
|
+
if (action === 'status') return continueDungeon(service, message);
|
|
71
|
+
|
|
72
|
+
const session = await service.getActiveByChannel(channelKey(message));
|
|
73
|
+
if (!session) return '当前频道没有远征队。发送「地牢 开始」创建。';
|
|
74
|
+
return handleDungeonChoice(
|
|
75
|
+
service,
|
|
76
|
+
message,
|
|
77
|
+
dungeonSessionToken(session),
|
|
78
|
+
action,
|
|
79
|
+
);
|
|
80
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { DeterministicRandom } from '@zhin.js/game-kit';
|
|
2
|
+
|
|
3
|
+
export const DUNGEON_SCHEMA_VERSION = 1;
|
|
4
|
+
export const MAX_PARTY_SIZE = 4;
|
|
5
|
+
export const MAX_FLOOR = 3;
|
|
6
|
+
export const BOSS_ROOM = 4;
|
|
7
|
+
|
|
8
|
+
export type DungeonPhase = 'lobby' | 'exploring' | 'combat' | 'completed';
|
|
9
|
+
export type DungeonResult = 'victory' | 'defeat' | 'aborted' | null;
|
|
10
|
+
|
|
11
|
+
export interface DungeonPlayer {
|
|
12
|
+
readonly id: string;
|
|
13
|
+
readonly name: string;
|
|
14
|
+
readonly maxHp: number;
|
|
15
|
+
readonly hp: number;
|
|
16
|
+
readonly guard: number;
|
|
17
|
+
readonly potions: number;
|
|
18
|
+
readonly gold: number;
|
|
19
|
+
readonly ready: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface DungeonEnemy {
|
|
23
|
+
readonly id: string;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
readonly hp: number;
|
|
26
|
+
readonly maxHp: number;
|
|
27
|
+
readonly attack: number;
|
|
28
|
+
readonly boss: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DungeonState {
|
|
32
|
+
readonly schemaVersion: 1;
|
|
33
|
+
readonly ownerId: string;
|
|
34
|
+
readonly phase: DungeonPhase;
|
|
35
|
+
readonly result: DungeonResult;
|
|
36
|
+
readonly floor: number;
|
|
37
|
+
readonly room: number;
|
|
38
|
+
readonly roomsCleared: number;
|
|
39
|
+
readonly turnIndex: number;
|
|
40
|
+
readonly players: readonly DungeonPlayer[];
|
|
41
|
+
readonly enemy: DungeonEnemy | null;
|
|
42
|
+
readonly log: readonly string[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type DungeonAction =
|
|
46
|
+
| { readonly type: 'join'; readonly name: string }
|
|
47
|
+
| { readonly type: 'ready' }
|
|
48
|
+
| { readonly type: 'start' }
|
|
49
|
+
| { readonly type: 'explore' }
|
|
50
|
+
| { readonly type: 'attack' }
|
|
51
|
+
| { readonly type: 'defend' }
|
|
52
|
+
| { readonly type: 'potion' }
|
|
53
|
+
| { readonly type: 'abort' };
|
|
54
|
+
|
|
55
|
+
export class DungeonRuleError extends Error {
|
|
56
|
+
constructor(message: string) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = 'DungeonRuleError';
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function createDungeonState(
|
|
63
|
+
ownerId: string,
|
|
64
|
+
ownerName: string,
|
|
65
|
+
): DungeonState {
|
|
66
|
+
return {
|
|
67
|
+
schemaVersion: DUNGEON_SCHEMA_VERSION,
|
|
68
|
+
ownerId,
|
|
69
|
+
phase: 'lobby',
|
|
70
|
+
result: null,
|
|
71
|
+
floor: 1,
|
|
72
|
+
room: 0,
|
|
73
|
+
roomsCleared: 0,
|
|
74
|
+
turnIndex: 0,
|
|
75
|
+
players: [createPlayer(ownerId, ownerName, true)],
|
|
76
|
+
enemy: null,
|
|
77
|
+
log: [`${ownerName} 创建了远征队。`],
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function applyDungeonAction(
|
|
82
|
+
state: Readonly<DungeonState>,
|
|
83
|
+
actorId: string,
|
|
84
|
+
action: DungeonAction,
|
|
85
|
+
random: DeterministicRandom,
|
|
86
|
+
): DungeonState {
|
|
87
|
+
if (state.phase === 'completed') {
|
|
88
|
+
throw new DungeonRuleError('本次远征已经结束。');
|
|
89
|
+
}
|
|
90
|
+
if (action.type === 'join') return joinParty(state, actorId, action.name);
|
|
91
|
+
const actorIndex = state.players.findIndex((player) => player.id === actorId);
|
|
92
|
+
if (actorIndex < 0) throw new DungeonRuleError('你还没有加入这支远征队。');
|
|
93
|
+
if (action.type === 'abort') {
|
|
94
|
+
if (state.ownerId !== actorId) {
|
|
95
|
+
throw new DungeonRuleError('只有队长可以结束整支远征。');
|
|
96
|
+
}
|
|
97
|
+
return appendLog({
|
|
98
|
+
...state,
|
|
99
|
+
phase: 'completed',
|
|
100
|
+
result: 'aborted',
|
|
101
|
+
enemy: null,
|
|
102
|
+
}, '队长结束了本次远征。');
|
|
103
|
+
}
|
|
104
|
+
if (action.type === 'ready') {
|
|
105
|
+
if (state.phase !== 'lobby') {
|
|
106
|
+
throw new DungeonRuleError('远征已经开始,无法修改准备状态。');
|
|
107
|
+
}
|
|
108
|
+
const players = state.players.map((player, index) =>
|
|
109
|
+
index === actorIndex ? { ...player, ready: !player.ready } : player);
|
|
110
|
+
return appendLog(
|
|
111
|
+
{ ...state, players },
|
|
112
|
+
`${players[actorIndex]?.name ?? actorId}${players[actorIndex]?.ready ? '已准备' : '取消准备'}。`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
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);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function activePlayer(state: Readonly<DungeonState>): DungeonPlayer | null {
|
|
124
|
+
if (state.players.length === 0) return null;
|
|
125
|
+
return state.players[state.turnIndex % state.players.length] ?? null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function decodeDungeonState(raw: string): DungeonState {
|
|
129
|
+
const parsed: unknown = JSON.parse(raw);
|
|
130
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
131
|
+
throw new TypeError('Invalid dungeon state');
|
|
132
|
+
}
|
|
133
|
+
const candidate = parsed as Partial<DungeonState>;
|
|
134
|
+
if (candidate.schemaVersion !== DUNGEON_SCHEMA_VERSION
|
|
135
|
+
|| !Array.isArray(candidate.players)
|
|
136
|
+
|| typeof candidate.ownerId !== 'string') {
|
|
137
|
+
throw new TypeError('Unsupported dungeon state schema');
|
|
138
|
+
}
|
|
139
|
+
return candidate as DungeonState;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function createPlayer(id: string, name: string, ready: boolean): DungeonPlayer {
|
|
143
|
+
return {
|
|
144
|
+
id,
|
|
145
|
+
name: name.trim() || id,
|
|
146
|
+
maxHp: 100,
|
|
147
|
+
hp: 100,
|
|
148
|
+
guard: 0,
|
|
149
|
+
potions: 2,
|
|
150
|
+
gold: 0,
|
|
151
|
+
ready,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function joinParty(
|
|
156
|
+
state: Readonly<DungeonState>,
|
|
157
|
+
actorId: string,
|
|
158
|
+
actorName: string,
|
|
159
|
+
): DungeonState {
|
|
160
|
+
if (state.phase !== 'lobby') {
|
|
161
|
+
throw new DungeonRuleError('远征已经出发,无法中途加入。');
|
|
162
|
+
}
|
|
163
|
+
if (state.players.some((player) => player.id === actorId)) {
|
|
164
|
+
throw new DungeonRuleError('你已经在远征队中。');
|
|
165
|
+
}
|
|
166
|
+
if (state.players.length >= MAX_PARTY_SIZE) {
|
|
167
|
+
throw new DungeonRuleError('远征队已满,最多 4 人。');
|
|
168
|
+
}
|
|
169
|
+
const player = createPlayer(actorId, actorName, false);
|
|
170
|
+
return appendLog(
|
|
171
|
+
{ ...state, players: [...state.players, player] },
|
|
172
|
+
`${player.name} 加入了远征队。`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function startExpedition(
|
|
177
|
+
state: Readonly<DungeonState>,
|
|
178
|
+
actorId: string,
|
|
179
|
+
): DungeonState {
|
|
180
|
+
if (state.phase !== 'lobby') {
|
|
181
|
+
throw new DungeonRuleError('远征已经开始。');
|
|
182
|
+
}
|
|
183
|
+
if (state.ownerId !== actorId) {
|
|
184
|
+
throw new DungeonRuleError('只有队长可以带队出发。');
|
|
185
|
+
}
|
|
186
|
+
const waiting = state.players.filter((player) => !player.ready);
|
|
187
|
+
if (waiting.length > 0) {
|
|
188
|
+
throw new DungeonRuleError(
|
|
189
|
+
`仍有队员未准备:${waiting.map((player) => player.name).join('、')}`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return appendLog({ ...state, phase: 'exploring' }, '远征队进入了第一层地牢。');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function assertActorTurn(state: Readonly<DungeonState>, actorId: string): void {
|
|
196
|
+
if (state.phase === 'lobby') {
|
|
197
|
+
throw new DungeonRuleError('请先由队长开始远征。');
|
|
198
|
+
}
|
|
199
|
+
const current = activePlayer(state);
|
|
200
|
+
if (!current || current.id !== actorId) {
|
|
201
|
+
throw new DungeonRuleError(`现在轮到 ${current?.name ?? '其他队员'} 行动。`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function explore(
|
|
206
|
+
state: Readonly<DungeonState>,
|
|
207
|
+
random: DeterministicRandom,
|
|
208
|
+
): DungeonState {
|
|
209
|
+
if (state.phase !== 'exploring') {
|
|
210
|
+
throw new DungeonRuleError('战斗尚未结束,无法继续探索。');
|
|
211
|
+
}
|
|
212
|
+
const room = state.room + 1;
|
|
213
|
+
if (room >= BOSS_ROOM) {
|
|
214
|
+
const enemy = createEnemy(state.floor, true, random);
|
|
215
|
+
return appendLog(
|
|
216
|
+
{ ...state, room, phase: 'combat', enemy },
|
|
217
|
+
`守层者 ${enemy.name} 挡住了去路!`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const roll = random.int(100);
|
|
221
|
+
if (roll < 58) {
|
|
222
|
+
const enemy = createEnemy(state.floor, false, random);
|
|
223
|
+
return appendLog(
|
|
224
|
+
{ ...state, room, phase: 'combat', enemy },
|
|
225
|
+
`遭遇了 ${enemy.name}!`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
if (roll < 78) {
|
|
229
|
+
const gold = random.intInclusive(8, 18);
|
|
230
|
+
return advanceAfterRoom(
|
|
231
|
+
{
|
|
232
|
+
...state,
|
|
233
|
+
room,
|
|
234
|
+
players: state.players.map((player) => ({
|
|
235
|
+
...player,
|
|
236
|
+
gold: player.gold + gold,
|
|
237
|
+
})),
|
|
238
|
+
},
|
|
239
|
+
`发现宝箱,每位队员获得 ${gold} 金币。`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
if (roll < 90) {
|
|
243
|
+
return advanceAfterRoom(
|
|
244
|
+
{
|
|
245
|
+
...state,
|
|
246
|
+
room,
|
|
247
|
+
players: state.players.map((player) => ({
|
|
248
|
+
...player,
|
|
249
|
+
hp: Math.min(player.maxHp, player.hp + 18),
|
|
250
|
+
})),
|
|
251
|
+
},
|
|
252
|
+
'发现治愈泉,队伍恢复了生命。',
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const damage = random.intInclusive(8, 16);
|
|
256
|
+
const targetIndex = nextLivingIndex(state.players, state.turnIndex);
|
|
257
|
+
const target = state.players[targetIndex];
|
|
258
|
+
const players = state.players.map((player, index) =>
|
|
259
|
+
index === targetIndex
|
|
260
|
+
? { ...player, hp: Math.max(0, player.hp - damage) }
|
|
261
|
+
: player);
|
|
262
|
+
const trapped = advanceAfterRoom(
|
|
263
|
+
{ ...state, room, players },
|
|
264
|
+
`${target?.name ?? '队员'} 触发陷阱,受到 ${damage} 点伤害。`,
|
|
265
|
+
);
|
|
266
|
+
const resolved = finishIfDefeated(trapped);
|
|
267
|
+
if (resolved.phase === 'completed' || (players[targetIndex]?.hp ?? 0) > 0) {
|
|
268
|
+
return resolved;
|
|
269
|
+
}
|
|
270
|
+
return advanceTurn(resolved);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function attack(
|
|
274
|
+
state: Readonly<DungeonState>,
|
|
275
|
+
actorIndex: number,
|
|
276
|
+
random: DeterministicRandom,
|
|
277
|
+
): DungeonState {
|
|
278
|
+
if (state.phase !== 'combat' || !state.enemy) {
|
|
279
|
+
throw new DungeonRuleError('当前没有可以攻击的敌人。');
|
|
280
|
+
}
|
|
281
|
+
const actor = state.players[actorIndex];
|
|
282
|
+
if (!actor || actor.hp <= 0) throw new DungeonRuleError('倒下的队员无法行动。');
|
|
283
|
+
const damage = random.intInclusive(12, 22);
|
|
284
|
+
const enemy = { ...state.enemy, hp: Math.max(0, state.enemy.hp - damage) };
|
|
285
|
+
let next = appendLog(
|
|
286
|
+
{ ...state, enemy },
|
|
287
|
+
`${actor.name} 对 ${enemy.name} 造成 ${damage} 点伤害。`,
|
|
288
|
+
);
|
|
289
|
+
if (enemy.hp <= 0) return defeatEnemy(next, random);
|
|
290
|
+
next = enemyTurn(next, actorIndex, random);
|
|
291
|
+
return advanceTurn(finishIfDefeated(next));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function defend(
|
|
295
|
+
state: Readonly<DungeonState>,
|
|
296
|
+
actorIndex: number,
|
|
297
|
+
random: DeterministicRandom,
|
|
298
|
+
): DungeonState {
|
|
299
|
+
if (state.phase !== 'combat' || !state.enemy) {
|
|
300
|
+
throw new DungeonRuleError('当前不需要防御。');
|
|
301
|
+
}
|
|
302
|
+
const actor = state.players[actorIndex];
|
|
303
|
+
if (!actor || actor.hp <= 0) throw new DungeonRuleError('倒下的队员无法行动。');
|
|
304
|
+
const players = state.players.map((player, index) =>
|
|
305
|
+
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));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function drinkPotion(
|
|
312
|
+
state: Readonly<DungeonState>,
|
|
313
|
+
actorIndex: number,
|
|
314
|
+
random: DeterministicRandom,
|
|
315
|
+
): DungeonState {
|
|
316
|
+
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('你的生命值已经满了。');
|
|
320
|
+
const players = state.players.map((player, index) =>
|
|
321
|
+
index === actorIndex
|
|
322
|
+
? {
|
|
323
|
+
...player,
|
|
324
|
+
hp: Math.min(player.maxHp, player.hp + 30),
|
|
325
|
+
potions: player.potions - 1,
|
|
326
|
+
}
|
|
327
|
+
: player);
|
|
328
|
+
let next = appendLog({ ...state, players }, `${actor.name} 使用药水恢复生命。`);
|
|
329
|
+
if (state.phase === 'combat') {
|
|
330
|
+
next = enemyTurn(next, actorIndex, random);
|
|
331
|
+
return advanceTurn(finishIfDefeated(next));
|
|
332
|
+
}
|
|
333
|
+
return next;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function enemyTurn(
|
|
337
|
+
state: Readonly<DungeonState>,
|
|
338
|
+
targetIndex: number,
|
|
339
|
+
random: DeterministicRandom,
|
|
340
|
+
): DungeonState {
|
|
341
|
+
const enemy = state.enemy;
|
|
342
|
+
const target = state.players[targetIndex];
|
|
343
|
+
if (!enemy || !target) return state;
|
|
344
|
+
const rawDamage = random.intInclusive(
|
|
345
|
+
Math.max(1, enemy.attack - 3),
|
|
346
|
+
enemy.attack + 3,
|
|
347
|
+
);
|
|
348
|
+
const damage = Math.max(0, rawDamage - target.guard);
|
|
349
|
+
const players = state.players.map((player, index) =>
|
|
350
|
+
index === targetIndex
|
|
351
|
+
? { ...player, hp: Math.max(0, player.hp - damage), guard: 0 }
|
|
352
|
+
: player);
|
|
353
|
+
return appendLog(
|
|
354
|
+
{ ...state, players },
|
|
355
|
+
`${enemy.name} 反击 ${target.name},造成 ${damage} 点伤害。`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function defeatEnemy(
|
|
360
|
+
state: Readonly<DungeonState>,
|
|
361
|
+
random: DeterministicRandom,
|
|
362
|
+
): DungeonState {
|
|
363
|
+
const enemy = state.enemy;
|
|
364
|
+
if (!enemy) return state;
|
|
365
|
+
const gold = random.intInclusive(enemy.boss ? 30 : 10, enemy.boss ? 50 : 22);
|
|
366
|
+
const rewarded = {
|
|
367
|
+
...state,
|
|
368
|
+
enemy: null,
|
|
369
|
+
players: state.players.map((player) => ({
|
|
370
|
+
...player,
|
|
371
|
+
gold: player.gold + gold,
|
|
372
|
+
})),
|
|
373
|
+
};
|
|
374
|
+
if (enemy.boss && state.floor >= MAX_FLOOR) {
|
|
375
|
+
return appendLog({
|
|
376
|
+
...rewarded,
|
|
377
|
+
phase: 'completed',
|
|
378
|
+
result: 'victory',
|
|
379
|
+
roomsCleared: state.roomsCleared + 1,
|
|
380
|
+
}, `击败最终守层者,每人获得 ${gold} 金币。远征胜利!`);
|
|
381
|
+
}
|
|
382
|
+
if (enemy.boss) {
|
|
383
|
+
return appendLog({
|
|
384
|
+
...rewarded,
|
|
385
|
+
phase: 'exploring',
|
|
386
|
+
floor: state.floor + 1,
|
|
387
|
+
room: 0,
|
|
388
|
+
roomsCleared: state.roomsCleared + 1,
|
|
389
|
+
}, `击败守层者,每人获得 ${gold} 金币。队伍进入下一层。`);
|
|
390
|
+
}
|
|
391
|
+
return advanceAfterRoom(
|
|
392
|
+
{ ...rewarded, phase: 'exploring' },
|
|
393
|
+
`击败 ${enemy.name},每人获得 ${gold} 金币。`,
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function createEnemy(
|
|
398
|
+
floor: number,
|
|
399
|
+
boss: boolean,
|
|
400
|
+
random: DeterministicRandom,
|
|
401
|
+
): DungeonEnemy {
|
|
402
|
+
const names = boss
|
|
403
|
+
? ['石像守卫', '深渊术士', '远古龙影']
|
|
404
|
+
: ['洞穴史莱姆', '骷髅巡卫', '暗影猎手', '失控魔像'];
|
|
405
|
+
const name = boss
|
|
406
|
+
? names[Math.min(names.length - 1, floor - 1)] as string
|
|
407
|
+
: random.item(names);
|
|
408
|
+
const maxHp = boss
|
|
409
|
+
? 90 + floor * 45
|
|
410
|
+
: random.intInclusive(35 + floor * 8, 52 + floor * 12);
|
|
411
|
+
return {
|
|
412
|
+
id: `${boss ? 'boss' : 'mob'}-${floor}-${random.nextUint32().toString(36)}`,
|
|
413
|
+
name,
|
|
414
|
+
hp: maxHp,
|
|
415
|
+
maxHp,
|
|
416
|
+
attack: boss ? 12 + floor * 3 : 7 + floor * 2,
|
|
417
|
+
boss,
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function advanceAfterRoom(
|
|
422
|
+
state: Readonly<DungeonState>,
|
|
423
|
+
message: string,
|
|
424
|
+
): DungeonState {
|
|
425
|
+
return appendLog({
|
|
426
|
+
...state,
|
|
427
|
+
roomsCleared: state.roomsCleared + 1,
|
|
428
|
+
}, message);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function advanceTurn(state: Readonly<DungeonState>): DungeonState {
|
|
432
|
+
if (state.phase === 'completed') return state as DungeonState;
|
|
433
|
+
const nextIndex = nextLivingIndex(state.players, state.turnIndex + 1);
|
|
434
|
+
return { ...state, turnIndex: nextIndex };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function nextLivingIndex(
|
|
438
|
+
players: readonly DungeonPlayer[],
|
|
439
|
+
start: number,
|
|
440
|
+
): number {
|
|
441
|
+
for (let offset = 0; offset < players.length; offset += 1) {
|
|
442
|
+
const index = (start + offset) % players.length;
|
|
443
|
+
if ((players[index]?.hp ?? 0) > 0) return index;
|
|
444
|
+
}
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function finishIfDefeated(state: Readonly<DungeonState>): DungeonState {
|
|
449
|
+
if (state.players.some((player) => player.hp > 0)) return state as DungeonState;
|
|
450
|
+
return appendLog({
|
|
451
|
+
...state,
|
|
452
|
+
phase: 'completed',
|
|
453
|
+
result: 'defeat',
|
|
454
|
+
enemy: null,
|
|
455
|
+
}, '远征队全员倒下,本次远征失败。');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function appendLog(
|
|
459
|
+
state: Readonly<DungeonState>,
|
|
460
|
+
message: string,
|
|
461
|
+
): DungeonState {
|
|
462
|
+
return {
|
|
463
|
+
...state,
|
|
464
|
+
log: [...state.log, message].slice(-8),
|
|
465
|
+
} as DungeonState;
|
|
466
|
+
}
|