@steve02081504/fount-p2p 0.0.30 → 0.0.32
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/AGENTS.md +79 -0
- package/docs/evfs.md +32 -0
- package/docs/infra.md +25 -0
- package/docs/mesh.md +67 -0
- package/docs/reputation.md +22 -0
- package/docs/runtime.md +62 -0
- package/docs/signaling.md +23 -0
- package/docs/transports.md +92 -0
- package/docs/wire.md +21 -0
- package/link/providers/webrtc.mjs +5 -2
- package/link/rtc/ice_local_hostname.mjs +15 -47
- package/link/rtc/polyfill.mjs +3 -15
- package/package.json +2 -9
- package/sim/AGENTS.md +29 -0
- package/sim/admission.mjs +56 -0
- package/sim/anomaly.mjs +39 -0
- package/sim/apply.mjs +77 -0
- package/sim/attack_space.mjs +113 -0
- package/sim/attacks.mjs +614 -0
- package/sim/behavior.mjs +78 -0
- package/sim/cli.mjs +282 -0
- package/sim/coevolution.mjs +245 -0
- package/sim/cold_start.md +13 -0
- package/sim/constants.mjs +44 -0
- package/sim/discovery.mjs +259 -0
- package/sim/duration.mjs +64 -0
- package/sim/graph_adj.mjs +28 -0
- package/sim/integrity.mjs +58 -0
- package/sim/metrics.mjs +423 -0
- package/sim/model.mjs +1176 -0
- package/sim/optimizer.mjs +250 -0
- package/sim/propagation.mjs +57 -0
- package/sim/report.mjs +176 -0
- package/sim/reputation_social.tunables.json +6 -0
- package/sim/rng.mjs +62 -0
- package/sim/scenarios.mjs +345 -0
- package/sim/sim_pool.mjs +169 -0
- package/sim/sim_worker.mjs +31 -0
- package/sim/social_reputation.mjs +30 -0
- package/sim/space.mjs +312 -0
- package/sim/test/admission.test.mjs +25 -0
- package/sim/test/attack_space.test.mjs +39 -0
- package/sim/test/behavior_vector.test.mjs +37 -0
- package/sim/test/coevolution.test.mjs +32 -0
- package/sim/test/cold_start.test.mjs +46 -0
- package/sim/test/defense.test.mjs +320 -0
- package/sim/test/discovery.test.mjs +68 -0
- package/sim/test/duration.test.mjs +20 -0
- package/sim/test/fidelity.test.mjs +151 -0
- package/sim/test/integrity.test.mjs +56 -0
- package/sim/test/optimizer.test.mjs +24 -0
- package/sim/test/propagation.test.mjs +29 -0
- package/sim/test/sim_model_regressions.test.mjs +110 -0
- package/sim/test/slow_drip.test.mjs +28 -0
- package/sim/test/smoke.test.mjs +52 -0
- package/sim/test/transport.test.mjs +55 -0
- package/sim/test/tunables_apply.test.mjs +64 -0
- package/sim/test/tunables_param_space.test.mjs +74 -0
- package/sim/test/vulnerability.test.mjs +127 -0
- package/sim/transport.mjs +135 -0
- package/sim/tunables_bundle.mjs +71 -0
- package/sim/vulnerability.mjs +532 -0
- package/deno.json +0 -7
- package/link/rtc/w3c_bridge.mjs +0 -152
package/sim/attacks.mjs
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 恶意节点行为原型。
|
|
3
|
+
*/
|
|
4
|
+
import { resolveAttackParams } from './attack_space.mjs'
|
|
5
|
+
import { SYBIL_REP_EARN_COST_ROUNDS } from './constants.mjs'
|
|
6
|
+
import { eclipseFillExplore } from './discovery.mjs'
|
|
7
|
+
import { enqueueSlash } from './propagation.mjs'
|
|
8
|
+
import { takeTransportJoinSlot, transportHintWeight } from './transport.mjs'
|
|
9
|
+
|
|
10
|
+
/** @typedef {'sybil' | 'collusion' | 'spammer' | 'false_accuser' | 'eclipse' | 'lazy_chunk' | 'social_mob' | 'archive_forger' | 'relay_farmer' | 'whitewasher' | 'report_flooder' | 'oscillator' | 'hint_poisoner' | 'key_thief' | 'sleeper' | 'equivocator' | 'targeted_eclipse' | 'rep_pump' | 'slow_drip_spammer' | 'signaling_flood' | 'signaling_eclipse'} AttackKind */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {object} simulationContext 仿真上下文
|
|
14
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
15
|
+
* @returns {ReturnType<typeof resolveAttackParams>} 有效攻击参数
|
|
16
|
+
*/
|
|
17
|
+
function attackParams(simulationContext, node) {
|
|
18
|
+
return resolveAttackParams(node.attack ?? 'sybil', simulationContext.attackGenome)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {object} simulationContext 仿真上下文
|
|
23
|
+
* @param {import('./model.mjs').SimObserver} observer 观察者
|
|
24
|
+
* @param {string} targetId 目标
|
|
25
|
+
* @param {string} senderId 发送者
|
|
26
|
+
* @param {number} claim 索赔
|
|
27
|
+
* @param {boolean} verified 是否已验证
|
|
28
|
+
* @param {number} round 回合
|
|
29
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 参数
|
|
30
|
+
* @returns {void}
|
|
31
|
+
*/
|
|
32
|
+
function queueSlash(simulationContext, observer, targetId, senderId, claim, verified, round, tunables) {
|
|
33
|
+
const state = simulationContext.propagationByObserver?.get(observer.id)
|
|
34
|
+
if (state) {
|
|
35
|
+
enqueueSlash(state, { targetId, senderId, claim, verified, birthRound: round, spread: 0 })
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
simulationContext.engine.applySubjectiveSlashPure(observer.reputation, targetId, senderId, claim, verified, tunables.reputation)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 第一人称:观察者仅因**直接观测到**该 peer 的中继行为而加分。
|
|
43
|
+
* @param {object} simulationContext 仿真上下文
|
|
44
|
+
* @param {import('./model.mjs').SimNode} node 被观测 peer
|
|
45
|
+
* @param {import('./model.mjs').SimObserver} observer 观察者
|
|
46
|
+
* @param {() => number} rng 随机源
|
|
47
|
+
* @param {number} rate 触发概率
|
|
48
|
+
* @param {string} dedupeKey 去重键
|
|
49
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 参数
|
|
50
|
+
* @returns {void}
|
|
51
|
+
*/
|
|
52
|
+
function observeSelfRelayBump(simulationContext, node, observer, rng, rate, dedupeKey, tunables) {
|
|
53
|
+
if (rng() >= rate) return
|
|
54
|
+
simulationContext.engine.bumpReputationOnRelayPure(
|
|
55
|
+
observer.reputation,
|
|
56
|
+
node.id,
|
|
57
|
+
dedupeKey,
|
|
58
|
+
simulationContext.now,
|
|
59
|
+
tunables.reputation,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 同伙互吹:经 hint 发现通道间接抬分,收益随 sender 主观信誉折扣(低信誉 sender 的 hint 几乎无效)。
|
|
65
|
+
* @param {object} simulationContext 仿真上下文
|
|
66
|
+
* @param {import('./model.mjs').SimObserver} observer 观察者
|
|
67
|
+
* @param {string} allyId 被抬分目标
|
|
68
|
+
* @param {string} senderId 声称来源
|
|
69
|
+
* @param {number} weightMul 相对 hintDefaultWeight 的倍数
|
|
70
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 参数
|
|
71
|
+
* @returns {void}
|
|
72
|
+
*/
|
|
73
|
+
function injectAllyHint(simulationContext, observer, allyId, senderId, weightMul, tunables) {
|
|
74
|
+
const senderRep = observer.reputation.byNodeHash[senderId]?.score ?? 0
|
|
75
|
+
const senderTrust = Math.max(0.05, (senderRep + 1) / 2)
|
|
76
|
+
const weight = tunables.trustGraph.hintDefaultWeight * weightMul * senderTrust * 0.35
|
|
77
|
+
const existing = observer.injectedHints.find(h => h.nodeHash === allyId && h.source === `ally:${senderId}`)
|
|
78
|
+
if (existing)
|
|
79
|
+
existing.weight = (existing.weight ?? weight) + weight
|
|
80
|
+
else
|
|
81
|
+
observer.injectedHints.push({ nodeHash: allyId, source: `ally:${senderId}`, weight })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {object} simulationContext 仿真上下文
|
|
86
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
87
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
88
|
+
* @param {() => number} rng 随机源
|
|
89
|
+
* @param {number} round 当前回合
|
|
90
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
91
|
+
* @returns {void}
|
|
92
|
+
*/
|
|
93
|
+
export function runAttack(simulationContext, node, observer, rng, round, tunables) {
|
|
94
|
+
switch (node.attack) {
|
|
95
|
+
case 'sybil':
|
|
96
|
+
runSybil(simulationContext, node, observer, rng, tunables)
|
|
97
|
+
break
|
|
98
|
+
case 'collusion':
|
|
99
|
+
runCollusion(simulationContext, node, observer, rng, round, tunables)
|
|
100
|
+
break
|
|
101
|
+
case 'spammer':
|
|
102
|
+
runSpammer(simulationContext, node, observer, tunables)
|
|
103
|
+
break
|
|
104
|
+
case 'false_accuser':
|
|
105
|
+
runFalseAccuser(simulationContext, node, observer, rng, round, tunables)
|
|
106
|
+
break
|
|
107
|
+
case 'eclipse':
|
|
108
|
+
runEclipse(simulationContext, node, observer, rng, round, tunables)
|
|
109
|
+
break
|
|
110
|
+
case 'lazy_chunk':
|
|
111
|
+
runLazyChunk(simulationContext, node, observer, tunables)
|
|
112
|
+
break
|
|
113
|
+
case 'social_mob':
|
|
114
|
+
runSocialMob(simulationContext, node, observer, rng, tunables)
|
|
115
|
+
break
|
|
116
|
+
case 'archive_forger':
|
|
117
|
+
runArchiveForger(simulationContext, node, observer, tunables)
|
|
118
|
+
break
|
|
119
|
+
case 'relay_farmer':
|
|
120
|
+
runRelayFarmer(simulationContext, node, observer, round, tunables)
|
|
121
|
+
break
|
|
122
|
+
case 'whitewasher':
|
|
123
|
+
runWhitewasher(simulationContext, node, observer, rng, round, tunables)
|
|
124
|
+
break
|
|
125
|
+
case 'report_flooder':
|
|
126
|
+
runReportFlooder(simulationContext, node, observer, rng, round, tunables)
|
|
127
|
+
break
|
|
128
|
+
case 'oscillator':
|
|
129
|
+
runOscillator(simulationContext, node, observer, round, tunables)
|
|
130
|
+
break
|
|
131
|
+
case 'hint_poisoner':
|
|
132
|
+
runHintPoisoner(simulationContext, node, observer, tunables)
|
|
133
|
+
break
|
|
134
|
+
case 'key_thief':
|
|
135
|
+
runKeyThief(simulationContext, node, observer, rng, round, tunables)
|
|
136
|
+
break
|
|
137
|
+
case 'sleeper':
|
|
138
|
+
runSleeper(simulationContext, node, observer, rng, round, tunables)
|
|
139
|
+
break
|
|
140
|
+
case 'equivocator':
|
|
141
|
+
runEquivocator(simulationContext, node, observer, tunables)
|
|
142
|
+
break
|
|
143
|
+
case 'targeted_eclipse':
|
|
144
|
+
runTargetedEclipse(simulationContext, node, observer, rng, round, tunables)
|
|
145
|
+
break
|
|
146
|
+
case 'rep_pump':
|
|
147
|
+
runRepPump(simulationContext, node, observer, rng, round, tunables)
|
|
148
|
+
break
|
|
149
|
+
case 'slow_drip_spammer':
|
|
150
|
+
runSlowDripSpammer(simulationContext, node, observer, round, tunables)
|
|
151
|
+
break
|
|
152
|
+
case 'signaling_flood':
|
|
153
|
+
runSignalingFlood(simulationContext, node, observer, round, tunables)
|
|
154
|
+
break
|
|
155
|
+
case 'signaling_eclipse':
|
|
156
|
+
runSignalingEclipse(simulationContext, node, observer, tunables)
|
|
157
|
+
break
|
|
158
|
+
default:
|
|
159
|
+
break
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {object} simulationContext 仿真上下文
|
|
165
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
166
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
167
|
+
* @param {() => number} rng 随机源
|
|
168
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
169
|
+
* @returns {void}
|
|
170
|
+
*/
|
|
171
|
+
function runSybil(simulationContext, node, observer, rng, tunables) {
|
|
172
|
+
if ((simulationContext.round ?? 0) < SYBIL_REP_EARN_COST_ROUNDS) return
|
|
173
|
+
const p = attackParams(simulationContext, node)
|
|
174
|
+
observeSelfRelayBump(simulationContext, node, observer, rng, p.activationRate, `sybil:${node.id}`, tunables)
|
|
175
|
+
for (const sybil of simulationContext.sybilCluster(node))
|
|
176
|
+
if (sybil.id !== node.id && rng() < p.activationRate * p.collusionAllyRate)
|
|
177
|
+
injectAllyHint(simulationContext, observer, sybil.id, node.id, 0.58, tunables)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {object} simulationContext 仿真上下文
|
|
182
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
183
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
184
|
+
* @param {() => number} rng 随机源
|
|
185
|
+
* @param {number} round 当前回合
|
|
186
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
187
|
+
* @returns {void}
|
|
188
|
+
*/
|
|
189
|
+
function runCollusion(simulationContext, node, observer, rng, round, tunables) {
|
|
190
|
+
const p = attackParams(simulationContext, node)
|
|
191
|
+
observeSelfRelayBump(simulationContext, node, observer, rng, p.activationRate, `collusion:${round}:${node.id}`, tunables)
|
|
192
|
+
const ring = simulationContext.collusionRing(node)
|
|
193
|
+
for (const ally of ring)
|
|
194
|
+
if (ally.id !== node.id && rng() < p.collusionAllyRate)
|
|
195
|
+
injectAllyHint(simulationContext, observer, ally.id, node.id, p.collusionAllyRate, tunables)
|
|
196
|
+
const victim = ring.find(n => n.id !== node.id)
|
|
197
|
+
if (victim && rng() < p.activationRate * 0.4)
|
|
198
|
+
queueSlash(simulationContext, observer, victim.id, node.id, tunables.reputation.slashUnverifiedDefaultClaim, false, round, tunables)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {object} simulationContext 仿真上下文
|
|
203
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
204
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
205
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
206
|
+
* @returns {void}
|
|
207
|
+
*/
|
|
208
|
+
function runSpammer(simulationContext, node, observer, tunables) {
|
|
209
|
+
const { recordMessageRateViolationPure } = simulationContext.engine
|
|
210
|
+
const burst = attackParams(simulationContext, node).burstSize
|
|
211
|
+
for (let i = 0; i < burst; i++)
|
|
212
|
+
recordMessageRateViolationPure(observer.reputation, node.id, tunables.reputation)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* @param {object} simulationContext 仿真上下文
|
|
217
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
218
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
219
|
+
* @param {() => number} rng 随机源
|
|
220
|
+
* @param {number} round 当前回合
|
|
221
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
222
|
+
* @returns {void}
|
|
223
|
+
*/
|
|
224
|
+
function runFalseAccuser(simulationContext, node, observer, rng, round, tunables) {
|
|
225
|
+
const honest = pickHonestTarget(simulationContext, observer, rng, attackParams(simulationContext, node).targetBiasHighRep)
|
|
226
|
+
if (!honest) return
|
|
227
|
+
queueSlash(
|
|
228
|
+
simulationContext,
|
|
229
|
+
observer,
|
|
230
|
+
honest.id,
|
|
231
|
+
node.id,
|
|
232
|
+
tunables.reputation.slashUnverifiedDefaultClaim * 2,
|
|
233
|
+
false,
|
|
234
|
+
round,
|
|
235
|
+
tunables,
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* @param {object} simulationContext 仿真上下文
|
|
241
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
242
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
243
|
+
* @param {() => number} rng 随机源
|
|
244
|
+
* @param {number} round 当前回合
|
|
245
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
246
|
+
* @returns {void}
|
|
247
|
+
*/
|
|
248
|
+
function runEclipse(simulationContext, node, observer, rng, round, tunables) {
|
|
249
|
+
const { recordGossipAllUnknownWantPure } = simulationContext.engine
|
|
250
|
+
const p = attackParams(simulationContext, node)
|
|
251
|
+
for (let i = 0; i < p.burstSize; i++)
|
|
252
|
+
recordGossipAllUnknownWantPure(observer.reputation, node.id, simulationContext.now + i, tunables.reputation)
|
|
253
|
+
const clusterIds = simulationContext.eclipseCluster(node).map(n => n.id).filter(id => id !== node.id)
|
|
254
|
+
eclipseFillExplore(simulationContext.discovery, observer.id, node.id, clusterIds, p.eclipseFocus)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {object} simulationContext 仿真上下文
|
|
259
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
260
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
261
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
262
|
+
* @returns {void}
|
|
263
|
+
*/
|
|
264
|
+
function runLazyChunk(simulationContext, node, observer, tunables) {
|
|
265
|
+
const { penalizeChunkStorageFailurePure } = simulationContext.engine
|
|
266
|
+
penalizeChunkStorageFailurePure(observer.reputation, node.id, tunables.reputation)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* @param {object} simulationContext 仿真上下文
|
|
271
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
272
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
273
|
+
* @param {() => number} rng 随机源
|
|
274
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
275
|
+
* @returns {void}
|
|
276
|
+
*/
|
|
277
|
+
function runSocialMob(simulationContext, node, observer, rng, tunables) {
|
|
278
|
+
const { applyFollowedBlockSignalPure } = simulationContext.socialEngine
|
|
279
|
+
const honest = pickHonestTarget(simulationContext, observer, rng)
|
|
280
|
+
if (!honest) return
|
|
281
|
+
applyFollowedBlockSignalPure(
|
|
282
|
+
observer.reputation,
|
|
283
|
+
{
|
|
284
|
+
followerNodeHash: node.id,
|
|
285
|
+
targetNodeHash: honest.id,
|
|
286
|
+
voterKey: `${node.id}entity`,
|
|
287
|
+
action: 'block',
|
|
288
|
+
selfTrust: false,
|
|
289
|
+
},
|
|
290
|
+
simulationContext.now,
|
|
291
|
+
tunables.social,
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* @param {object} simulationContext 仿真上下文
|
|
297
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
298
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
299
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
300
|
+
* @returns {void}
|
|
301
|
+
*/
|
|
302
|
+
function runArchiveForger(simulationContext, node, observer, tunables) {
|
|
303
|
+
const { penalizeArchiveServeMismatchPure } = simulationContext.engine
|
|
304
|
+
penalizeArchiveServeMismatchPure(observer.reputation, node.id, tunables.reputation)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* @param {object} simulationContext 仿真上下文
|
|
309
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
310
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
311
|
+
* @param {number} round 当前回合
|
|
312
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
313
|
+
* @returns {void}
|
|
314
|
+
*/
|
|
315
|
+
function runRelayFarmer(simulationContext, node, observer, round, tunables) {
|
|
316
|
+
const { bumpReputationOnRelayPure } = simulationContext.engine
|
|
317
|
+
const burst = attackParams(simulationContext, node).burstSize
|
|
318
|
+
for (let i = 0; i < burst; i++)
|
|
319
|
+
bumpReputationOnRelayPure(observer.reputation, node.id, `relay-farm:${round}:${i}`, simulationContext.now + i, tunables.reputation)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* @param {object} simulationContext 仿真上下文
|
|
324
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
325
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
326
|
+
* @param {() => number} rng 随机源
|
|
327
|
+
* @param {number} round 当前回合
|
|
328
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
329
|
+
* @returns {void}
|
|
330
|
+
*/
|
|
331
|
+
function runWhitewasher(simulationContext, node, observer, rng, round, tunables) {
|
|
332
|
+
const { applySubjectiveSlashPure, seedMemberReputationFromIntroducerPure } = simulationContext.engine
|
|
333
|
+
const stage = node.whitewashStage ?? 0
|
|
334
|
+
if (stage === 0 && round >= 4) {
|
|
335
|
+
applySubjectiveSlashPure(
|
|
336
|
+
observer.reputation,
|
|
337
|
+
node.id,
|
|
338
|
+
observer.id,
|
|
339
|
+
tunables.reputation.slashUnverifiedDefaultClaim,
|
|
340
|
+
false,
|
|
341
|
+
tunables.reputation,
|
|
342
|
+
)
|
|
343
|
+
node.whitewashStage = 1
|
|
344
|
+
}
|
|
345
|
+
else if (stage === 1 && round % 7 === 0) {
|
|
346
|
+
const intro = pickHonestTarget(simulationContext, observer, rng)
|
|
347
|
+
if (intro) {
|
|
348
|
+
// 用 tunable introducerSeedEdge(不再写死 0.6):边权越低,洗白者重新入场时
|
|
349
|
+
// 从诚实介绍者继承的信誉越少,越难「洗白复活」。
|
|
350
|
+
delete observer.reputation.byNodeHash[node.id]
|
|
351
|
+
seedMemberReputationFromIntroducerPure(observer.reputation, node.id, intro.id, undefined, tunables.reputation)
|
|
352
|
+
node.whitewashStage = 2
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* @param {object} simulationContext 仿真上下文
|
|
359
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
360
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
361
|
+
* @param {() => number} rng 随机源
|
|
362
|
+
* @param {number} round 当前回合
|
|
363
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
364
|
+
* @returns {void}
|
|
365
|
+
*/
|
|
366
|
+
function runReportFlooder(simulationContext, node, observer, rng, round, tunables) {
|
|
367
|
+
const targets = simulationContext.honestNodes.filter(n => n.id !== observer.id)
|
|
368
|
+
const burst = Math.min(attackParams(simulationContext, node).burstSize, targets.length)
|
|
369
|
+
for (let i = 0; i < burst; i++) {
|
|
370
|
+
const target = targets[Math.floor(rng() * targets.length)]
|
|
371
|
+
if (!target) continue
|
|
372
|
+
queueSlash(
|
|
373
|
+
simulationContext,
|
|
374
|
+
observer,
|
|
375
|
+
target.id,
|
|
376
|
+
node.id,
|
|
377
|
+
tunables.reputation.slashUnverifiedDefaultClaim * 1.5,
|
|
378
|
+
false,
|
|
379
|
+
round,
|
|
380
|
+
tunables,
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* @param {object} simulationContext 仿真上下文
|
|
387
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
388
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
389
|
+
* @param {number} round 当前回合
|
|
390
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
391
|
+
* @returns {void}
|
|
392
|
+
*/
|
|
393
|
+
function runOscillator(simulationContext, node, observer, round, tunables) {
|
|
394
|
+
const { bumpChunkStorageReputationPure, penalizeChunkStorageFailurePure } = simulationContext.engine
|
|
395
|
+
if (round % 2 === 0)
|
|
396
|
+
bumpChunkStorageReputationPure(observer.reputation, node.id, tunables.reputation)
|
|
397
|
+
else
|
|
398
|
+
penalizeChunkStorageFailurePure(observer.reputation, node.id, tunables.reputation)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* 提示投毒:向观察者注入指向自己的发现提示,权重随 hintDefaultWeight 缩放。
|
|
403
|
+
* @param {object} simulationContext 仿真上下文
|
|
404
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
405
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
406
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
407
|
+
* @returns {void}
|
|
408
|
+
*/
|
|
409
|
+
function runHintPoisoner(simulationContext, node, observer, tunables) {
|
|
410
|
+
const mul = attackParams(simulationContext, node).hintWeightMul
|
|
411
|
+
const weight = tunables.trustGraph.hintDefaultWeight * mul
|
|
412
|
+
const existing = observer.injectedHints.find(h => h.nodeHash === node.id)
|
|
413
|
+
if (existing)
|
|
414
|
+
existing.weight = (existing.weight ?? weight) + weight
|
|
415
|
+
else
|
|
416
|
+
observer.injectedHints.push({ nodeHash: node.id, source: 'poison', weight })
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* 盗号/同 hash:继承受害者已积累的信誉后作恶。
|
|
421
|
+
* @param {object} simulationContext 仿真上下文
|
|
422
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
423
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
424
|
+
* @param {() => number} rng 随机源
|
|
425
|
+
* @param {number} round 当前回合
|
|
426
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
427
|
+
* @returns {void}
|
|
428
|
+
*/
|
|
429
|
+
function runKeyThief(simulationContext, node, observer, rng, round, tunables) {
|
|
430
|
+
const { bumpReputationOnRelayPure, recordMessageRateViolationPure, penalizeArchiveServeMismatchPure } = simulationContext.engine
|
|
431
|
+
const victimId = node.stolenFromId
|
|
432
|
+
if (!victimId) return
|
|
433
|
+
|
|
434
|
+
// 首次回合:复制受害者信誉分
|
|
435
|
+
if (round === 0 && observer.reputation.byNodeHash[victimId]?.score != null) {
|
|
436
|
+
const victimScore = observer.reputation.byNodeHash[victimId].score
|
|
437
|
+
observer.reputation.byNodeHash[node.id] = { score: victimScore }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (round < 3) {
|
|
441
|
+
if (rng() < 0.5)
|
|
442
|
+
bumpReputationOnRelayPure(observer.reputation, node.id, `key-thief-warm:${round}`, simulationContext.now, tunables.reputation)
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// 盗号后作恶
|
|
447
|
+
if (rng() < 0.7)
|
|
448
|
+
recordMessageRateViolationPure(observer.reputation, node.id, tunables.reputation)
|
|
449
|
+
if (rng() < 0.4)
|
|
450
|
+
penalizeArchiveServeMismatchPure(observer.reputation, node.id, tunables.reputation)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* 肉鸡突变:前期诚实刷信誉,turnRound 后突然转恶。
|
|
455
|
+
* @param {object} simulationContext 仿真上下文
|
|
456
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
457
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
458
|
+
* @param {() => number} rng 随机源
|
|
459
|
+
* @param {number} round 当前回合
|
|
460
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
461
|
+
* @returns {void}
|
|
462
|
+
*/
|
|
463
|
+
function runSleeper(simulationContext, node, observer, rng, round, tunables) {
|
|
464
|
+
const { bumpReputationOnRelayPure, bumpChunkStorageReputationPure, recordMessageRateViolationPure } = simulationContext.engine
|
|
465
|
+
const p = attackParams(simulationContext, node)
|
|
466
|
+
const turnRound = node.sleeperTurnRound ?? simulationContext.sleeperTurnRound ?? 15
|
|
467
|
+
|
|
468
|
+
if (round < turnRound) {
|
|
469
|
+
if (rng() < p.activationRate)
|
|
470
|
+
bumpReputationOnRelayPure(observer.reputation, node.id, `sleeper-good:${round}`, simulationContext.now, tunables.reputation)
|
|
471
|
+
if (rng() < 0.4)
|
|
472
|
+
bumpChunkStorageReputationPure(observer.reputation, node.id, tunables.reputation)
|
|
473
|
+
return
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
for (let i = 0; i < Math.max(1, Math.floor(p.burstSize / 2)); i++)
|
|
477
|
+
recordMessageRateViolationPure(observer.reputation, node.id, tunables.reputation)
|
|
478
|
+
const honest = pickHonestTarget(simulationContext, observer, rng, p.targetBiasHighRep)
|
|
479
|
+
if (honest && rng() < p.activationRate * 0.5)
|
|
480
|
+
queueSlash(simulationContext, observer, honest.id, node.id, tunables.reputation.slashUnverifiedDefaultClaim, false, round, tunables)
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* digest 等价欺骗:对不同观察者上报不同归档摘要(记录在 simulationContext.equivocationByObserver)。
|
|
485
|
+
* @param {object} simulationContext 仿真上下文
|
|
486
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
487
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
488
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
489
|
+
* @returns {void}
|
|
490
|
+
*/
|
|
491
|
+
function runEquivocator(simulationContext, node, observer, tunables) {
|
|
492
|
+
const { penalizeArchiveServeMismatchPure } = simulationContext.engine
|
|
493
|
+
penalizeArchiveServeMismatchPure(observer.reputation, node.id, tunables.reputation)
|
|
494
|
+
if (!simulationContext.equivocationByObserver) simulationContext.equivocationByObserver = new Map()
|
|
495
|
+
const key = `${node.id}:${observer.id}`
|
|
496
|
+
simulationContext.equivocationByObserver.set(key, (simulationContext.equivocationByObserver.get(key) ?? 0) + 1)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* 定向 eclipse:对单一受害诚实节点做分区(标记 simulationContext.eclipseTargets)。
|
|
501
|
+
* @param {object} simulationContext 仿真上下文
|
|
502
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
503
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
504
|
+
* @param {() => number} rng 随机源
|
|
505
|
+
* @param {number} round 当前回合
|
|
506
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
507
|
+
* @returns {void}
|
|
508
|
+
*/
|
|
509
|
+
function runTargetedEclipse(simulationContext, node, observer, rng, round, tunables) {
|
|
510
|
+
const { recordGossipAllUnknownWantPure } = simulationContext.engine
|
|
511
|
+
const p = attackParams(simulationContext, node)
|
|
512
|
+
const targetId = node.eclipseTargetId ?? simulationContext.eclipseTargetId
|
|
513
|
+
if (targetId)
|
|
514
|
+
simulationContext.eclipseVictims = simulationContext.eclipseVictims ?? new Set()
|
|
515
|
+
for (let i = 0; i < p.burstSize; i++)
|
|
516
|
+
recordGossipAllUnknownWantPure(observer.reputation, node.id, simulationContext.now + i, tunables.reputation)
|
|
517
|
+
if (targetId) {
|
|
518
|
+
simulationContext.eclipseVictims.add(targetId)
|
|
519
|
+
eclipseFillExplore(simulationContext.discovery, observer.id, node.id, [node.id], p.eclipseFocus)
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* pump-and-dump:sybil 簇交叉抬升一个身份进 topK 后再伪造。
|
|
525
|
+
* @param {object} simulationContext 仿真上下文
|
|
526
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
527
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
528
|
+
* @param {() => number} rng 随机源
|
|
529
|
+
* @param {number} round 当前回合
|
|
530
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
531
|
+
* @returns {void}
|
|
532
|
+
*/
|
|
533
|
+
function runRepPump(simulationContext, node, observer, rng, round, tunables) {
|
|
534
|
+
const { penalizeArchiveServeMismatchPure } = simulationContext.engine
|
|
535
|
+
const p = attackParams(simulationContext, node)
|
|
536
|
+
const pumpRounds = Math.max(8, Math.round((simulationContext.scenario?.rounds ?? 40) * p.sleeperTurnFrac))
|
|
537
|
+
if (round < pumpRounds) {
|
|
538
|
+
observeSelfRelayBump(simulationContext, node, observer, rng, p.activationRate, `pump:${round}:${node.id}`, tunables)
|
|
539
|
+
for (const sybil of simulationContext.sybilCluster(node))
|
|
540
|
+
if (sybil.id !== node.id && rng() < p.activationRate * p.collusionAllyRate)
|
|
541
|
+
injectAllyHint(simulationContext, observer, sybil.id, node.id, 0.5, tunables)
|
|
542
|
+
return
|
|
543
|
+
}
|
|
544
|
+
if (rng() < p.activationRate)
|
|
545
|
+
penalizeArchiveServeMismatchPure(observer.reputation, node.id, tunables.reputation)
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* 慢滴 spammer:紧贴速率阈值下方刷量。
|
|
550
|
+
* @param {object} simulationContext 仿真上下文
|
|
551
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
552
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
553
|
+
* @param {number} round 当前回合
|
|
554
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 候选参数
|
|
555
|
+
* @returns {void}
|
|
556
|
+
*/
|
|
557
|
+
function runSlowDripSpammer(simulationContext, node, observer, round, tunables) {
|
|
558
|
+
const { recordMessageRateViolationPure } = simulationContext.engine
|
|
559
|
+
if (round % 3 !== 0) return
|
|
560
|
+
recordMessageRateViolationPure(observer.reputation, node.id, tunables.reputation)
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 信令 join 洪水:占满 RTC 槽位触发过载冷却。
|
|
565
|
+
* @param {object} simulationContext 仿真上下文
|
|
566
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
567
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
568
|
+
* @param {number} round 当前回合
|
|
569
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 参数
|
|
570
|
+
* @returns {void}
|
|
571
|
+
*/
|
|
572
|
+
function runSignalingFlood(simulationContext, node, observer, round, tunables) {
|
|
573
|
+
const burst = attackParams(simulationContext, node).burstSize
|
|
574
|
+
const transport = simulationContext.transportByObserver?.get(observer.id)
|
|
575
|
+
if (!transport) return
|
|
576
|
+
for (let i = 0; i < burst; i++)
|
|
577
|
+
takeTransportJoinSlot(transport, `${node.id}:flood:${round}:${i}`, `flood:${node.id}`, simulationContext.now + i)
|
|
578
|
+
void tunables
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* 信令 eclipse:单源占槽 + 低权重 hint(单源仍可连,多源有软加成)。
|
|
583
|
+
* @param {object} simulationContext 仿真上下文
|
|
584
|
+
* @param {import('./model.mjs').SimNode} node 恶意节点
|
|
585
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
586
|
+
* @param {import('./tunables_bundle.mjs').TunablesBundle} tunables 参数
|
|
587
|
+
* @returns {void}
|
|
588
|
+
*/
|
|
589
|
+
function runSignalingEclipse(simulationContext, node, observer, tunables) {
|
|
590
|
+
const transport = simulationContext.transportByObserver?.get(observer.id)
|
|
591
|
+
if (!transport) return
|
|
592
|
+
takeTransportJoinSlot(transport, node.id, `sig:${node.id}`, simulationContext.now)
|
|
593
|
+
transportHintWeight(transport, observer.id, node.id, `sig:${node.id}`, tunables.trustGraph.hintDefaultWeight * 0.5)
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* @param {object} simulationContext 仿真上下文
|
|
598
|
+
* @param {import('./model.mjs').SimObserver} observer 诚实观察者
|
|
599
|
+
* @param {() => number} rng 随机源
|
|
600
|
+
* @param {number} [highRepBias=0] 高信誉目标偏好 0..1
|
|
601
|
+
* @returns {import('./model.mjs').SimNode | null} 随机诚实目标,无则 null
|
|
602
|
+
*/
|
|
603
|
+
function pickHonestTarget(simulationContext, observer, rng, highRepBias = 0) {
|
|
604
|
+
const honest = simulationContext.honestNodes.filter(n => n.id !== observer.id)
|
|
605
|
+
if (!honest.length) return null
|
|
606
|
+
if (highRepBias > 0 && rng() < highRepBias) {
|
|
607
|
+
const scored = honest
|
|
608
|
+
.map(n => ({ n, s: observer.reputation.byNodeHash[n.id]?.score ?? 0 }))
|
|
609
|
+
.sort((a, b) => b.s - a.s)
|
|
610
|
+
const top = scored.slice(0, Math.max(1, Math.ceil(honest.length * 0.25)))
|
|
611
|
+
return top[Math.floor(rng() * top.length)]?.n ?? honest[Math.floor(rng() * honest.length)]
|
|
612
|
+
}
|
|
613
|
+
return honest[Math.floor(rng() * honest.length)]
|
|
614
|
+
}
|
package/sim/behavior.mjs
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 诚实节点浮点行为向量(每维 [0,1])。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** @typedef {'postRate' | 'likeRate' | 'replyRate' | 'relayRate' | 'chunkServeRate' | 'onlineStability' | 'blockProneness' | 'archiveSubmitRate' | 'mentionRate' | 'burstPostRate'} BehaviorKey */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Record<BehaviorKey, number>} NodeBehavior
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Partial<Record<BehaviorKey, { min?: number, max?: number, mean?: number }>>} BehaviorDist
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @type {BehaviorKey[]} */
|
|
16
|
+
export const BEHAVIOR_KEYS = Object.freeze([
|
|
17
|
+
'postRate',
|
|
18
|
+
'likeRate',
|
|
19
|
+
'replyRate',
|
|
20
|
+
'relayRate',
|
|
21
|
+
'chunkServeRate',
|
|
22
|
+
'onlineStability',
|
|
23
|
+
'blockProneness',
|
|
24
|
+
'archiveSubmitRate',
|
|
25
|
+
'mentionRate',
|
|
26
|
+
'burstPostRate',
|
|
27
|
+
])
|
|
28
|
+
|
|
29
|
+
/** 均衡默认分布 */
|
|
30
|
+
export const DEFAULT_BEHAVIOR_DIST = Object.freeze({
|
|
31
|
+
postRate: { mean: 0.45, min: 0.1, max: 0.9 },
|
|
32
|
+
likeRate: { mean: 0.35, min: 0, max: 0.95 },
|
|
33
|
+
replyRate: { mean: 0.3, min: 0, max: 0.85 },
|
|
34
|
+
relayRate: { mean: 0.4, min: 0.05, max: 0.9 },
|
|
35
|
+
chunkServeRate: { mean: 0.35, min: 0, max: 0.8 },
|
|
36
|
+
onlineStability: { mean: 0.75, min: 0.3, max: 1 },
|
|
37
|
+
blockProneness: { mean: 0.08, min: 0, max: 0.4 },
|
|
38
|
+
archiveSubmitRate: { mean: 0.25, min: 0, max: 0.7 },
|
|
39
|
+
mentionRate: { mean: 0.15, min: 0, max: 0.6 },
|
|
40
|
+
burstPostRate: { mean: 0, min: 0, max: 0.5 },
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {() => number} rng 随机源
|
|
45
|
+
* @param {BehaviorDist} [dist] 分布
|
|
46
|
+
* @returns {NodeBehavior} 采样行为向量
|
|
47
|
+
*/
|
|
48
|
+
export function sampleBehavior(rng, dist = {}) {
|
|
49
|
+
/** @type {Partial<NodeBehavior>} */
|
|
50
|
+
const behavior = {}
|
|
51
|
+
for (const key of BEHAVIOR_KEYS) {
|
|
52
|
+
const spec = dist[key] ?? DEFAULT_BEHAVIOR_DIST[key] ?? { mean: 0.5 }
|
|
53
|
+
const min = spec.min ?? 0
|
|
54
|
+
const max = spec.max ?? 1
|
|
55
|
+
const mean = spec.mean ?? (min + max) / 2
|
|
56
|
+
const spread = (max - min) * 0.5
|
|
57
|
+
behavior[key] = Math.max(min, Math.min(max, mean + (rng() - 0.5) * spread))
|
|
58
|
+
}
|
|
59
|
+
return /** @type {NodeBehavior} */ behavior
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {NodeBehavior} behavior 行为向量
|
|
64
|
+
* @returns {boolean} 是否「只点赞不发帖」型静默用户
|
|
65
|
+
*/
|
|
66
|
+
export function isQuietHonestBehavior(behavior) {
|
|
67
|
+
return behavior.postRate < 0.15 && behavior.likeRate >= 0.4 && behavior.replyRate < 0.25
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {() => number} rng 随机源
|
|
72
|
+
* @param {NodeBehavior} behavior 行为向量
|
|
73
|
+
* @param {BehaviorKey} key 维度
|
|
74
|
+
* @returns {boolean} 本回合是否触发
|
|
75
|
+
*/
|
|
76
|
+
export function behaviorRoll(rng, behavior, key) {
|
|
77
|
+
return rng() < (behavior[key] ?? 0)
|
|
78
|
+
}
|