-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathGameServer.bb
More file actions
1521 lines (1402 loc) · 52.4 KB
/
GameServer.bb
File metadata and controls
1521 lines (1402 loc) · 52.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
;##############################################################################################################################
;HISTORY:
;
;Actor moves/change areas standard update timing fix. 8/16/2007 Rofar. set IgnoreUpdate flag in change area function
;
;##############################################################################################################################
Type GameWindow
Field Window
Field LTime, LDate
Field MessageText, MessageButton
Field SendMessageText, SendMessageButton
Field AreaCombo, PlayersList, BootButton
Field ChatText, ChatLogMode, ChatLogFlushButton
Field RefreshScriptsButton
Field LScripts
End Type
Global Game.GameWindow
Global LastSpellRecharge
Global GameArea.Area
Global LoginMessage$
; Creates the Game window
Function CreateGameWindow.GameWindow()
G.GameWindow = New GameWindow
G\Window = CreateWindow("Game", 10, 470, 500, 580, Desktop(), 1)
G\LTime = CreateLabel("Game time: ", 10, 10, 350, 20, G\Window)
G\LDate = CreateLabel("Game date: ", 10, 30, 350, 20, G\Window)
G\RefreshScriptsButton = CreateButton("Refresh scripts", 400, 10, 90, 25, G\Window)
G\LScripts = CreateLabel("Scripts:", 410, 40, 90, 20, G\Window)
CreateLabel("Login message:", 10, 60, 150, 20, G\Window)
G\MessageText = CreateTextField(10, 82, 350, 23, G\Window)
G\MessageButton = CreateButton("Update", 370, 80, 100, 25, G\Window)
CreateLabel("Global message:", 10, 110, 150, 20, G\Window)
G\SendMessageText = CreateTextField(10, 132, 350, 23, G\Window)
G\SendMessageButton = CreateButton("Send", 370, 130, 100, 25, G\Window)
CreateLabel("View zone:", 10, 182, 150, 20, G\Window)
G\AreaCombo = CreateComboBox(65, 180, 320, 20, G\Window)
CreateLabel("Players in zone:", 10, 220, 150, 20, G\Window)
G\PlayersList = CreateListBox(10, 240, 200, 100, G\Window)
CreateLabel("Zone chat:", 10, 350, 150, 20, G\Window)
G\ChatText = CreateListBox(10, 370, 440, 150, G\Window)
CreateLabel("Log chat messages:", 10, 527, 100, 20, G\Window)
G\ChatLogMode = CreateComboBox(110, 525, 90, 20, G\Window)
AddGadgetItem(G\ChatLogMode, "Never")
AddGadgetItem(G\ChatLogMode, "This zone only")
AddGadgetItem(G\ChatLogMode, "Always", True)
G\ChatLogFlushButton = CreateButton("Flush chat log", 250, 525, 90, 20, G\Window)
G\BootButton = CreateButton("Boot player", 230, 240, 100, 25, G\Window)
Return G
End Function
; Gives XP points to an actor instance
Function GiveXP(A.ActorInstance, XP, IgnoreParty = 0)
; Give the points to the leader, if this actor has a leader
If A\Leader <> Null
GiveXP(A\Leader, XP, IgnoreParty)
Return
EndIf
; Share with other party members in same area, if any
If IgnoreParty = 0
Party.Party = Object.Party(A\PartyID)
If Party <> Null
Members = 0
For i = 0 To 7
If Party\Player[i] <> Null
If Party\Player[i]\ServerArea = A\ServerArea Then Members = Members + 1
EndIf
Next
; Skip the share path entirely if no in-area members were
; counted. Previously `XP / Members` and `XP Mod Party\Members`
; could fire with Members or Party\Members = 0 (everyone in the
; party in different zones, or a desynced party state), crashing
; the server on every kill that yielded XP.
If Members > 0 And Party\Members > 0
PartyXP = XP / Members
For i = 0 To 7
If Party\Player[i] <> Null And Party\Player[i] <> A
If Party\Player[i]\ServerArea = A\ServerArea Then GiveXP(Party\Player[i], PartyXP, True)
EndIf
Next
; The recipient (A) keeps its share plus the division remainder.
; The remainder MUST use the SAME divisor as the split above --
; the in-area `Members`, not the total-party `Party\Members`.
; Each of the (Members-1) other in-area members received
; PartyXP = XP / Members, so for the distributed total to equal
; XP exactly, A must keep PartyXP + (XP Mod Members). Using
; Party\Members here fabricated or dropped XP whenever the party
; spanned zones (Members <> Party\Members): e.g. XP=10, Members=2,
; Party\Members=3 gave 5 + (10 Mod 3)=6, total 11 from a 10 award.
XP = PartyXP + (XP Mod Members)
EndIf
EndIf
EndIf
; Add gain to character
A\XP = A\XP + XP
; Call script and tell player if it's a human character
If A\RNID > 0
ThreadScript("LevelUp", "Main", Handle(A), 0)
RCE_Send(Host, A\RNID, P_XPUpdate, "M" + RCE_StrFromInt$(XP, 4), True)
EndIf
End Function
; Kills off an actor instance
; Deferred-kill queue. KillActor can FreeActorInstance(A), and when KillActor
; is invoked from inside `For AI.ActorInstance = Each ActorInstance` (e.g.
; water-damage death in UpdateActorInstances), freeing the current iterator
; leaves Blitz's For-Each next-pointer pointing into freed memory. The
; iteration sites use DeferKillActor instead, then call ProcessPendingKills
; after the loop completes.
Type PendingKill
Field Actor.ActorInstance
Field Killer.ActorInstance
End Type
Function DeferKillActor(A.ActorInstance, Killer.ActorInstance)
If A = Null Then Return
PK.PendingKill = New PendingKill
PK\Actor = A
PK\Killer = Killer
End Function
Function ProcessPendingKills()
For PK.PendingKill = Each PendingKill
If PK\Actor <> Null Then KillActor(PK\Actor, PK\Killer)
Next
Delete Each PendingKill
End Function
Function KillActor(A.ActorInstance, Killer.ActorInstance)
; Tell players in the same area if it was an AI actor dying. If the
; actor's ServerArea is stale (warp-in-progress, freed area, or any
; cleanup race), Object.AreaInstance returns Null -- skip the
; broadcast rather than crash on AInstance\FirstInZone.
If A\RNID < 0
Pa$ = RCE_StrFromInt$(A\RuntimeID, 2)
If Killer <> Null Then Pa$ = Pa$ + RCE_StrFromInt$(Killer\RuntimeID, 2)
AInstance.AreaInstance = Object.AreaInstance(A\ServerArea)
If AInstance <> Null
A2.ActorInstance = AInstance\FirstInZone
While A2 <> Null
If A2\RNID > 0 Then RCE_Send(Host, A2\RNID, P_ActorDead, Pa$, True)
A2 = A2\NextInZone
Wend
EndIf
EndIf
If Killer <> Null
; Reduce faction rating if it isn't already at -100%
Killer\FactionRatings[A\HomeFaction] = Killer\FactionRatings[A\HomeFaction] - CombatRatingAdjust
If Killer\FactionRatings[A\HomeFaction] < 0 Then Killer\FactionRatings[A\HomeFaction] = 0
; Give XP to the killer
Diff = A\Level - Killer\Level
If Diff < 1 Then Diff = 1
XP = (Diff * A\Actor\XPMultiplier) + Rand(0, 20)
GiveXP(Killer, XP)
EndIf
; Continue any paused scripts waiting for this event. After-cursor walk:
; the body Deletes PS, which would corrupt a For-Each cursor on the
; next iteration step if two WaitKill scripts resume in the same kill.
Local PS.PausedScript = First PausedScript
Local PSNext.PausedScript = Null
While PS <> Null
PSNext = After PS
; Defense in depth: drop any PausedScript with Null PS\S so the
; deref below can't fault. The BVM_SETWAIT* setters early-return
; on Null SI (#228), but a stale queue entry or future code path
; that bypasses the SI guard would otherwise crash the server.
If PS\S = Null
Delete PS
ElseIf PS\Reason = 2
If PS\ReasonActor = Killer And PS\ReasonKillActor = A\Actor
PS\ReasonCount = PS\ReasonCount + 1
If PS\ReasonCount >= PS\ReasonAmount
PS\S\WaitResult$ = "1"
Delete PS
EndIf
EndIf
EndIf
PS = PSNext
Wend
; Human death
If A\RNID > 0
; Run script
ThreadScript("Death", "Main", Handle(A), Handle(Killer))
; Any AI actors targeting this player should stop
For A2.ActorInstance = Each ActorInstance
If A2\AITarget = A Then A2\AITarget = Null
Next
; Remove AI actors from game
Else
; Optional AI death script
Params$ = A\Actor\Race$ + "," + A\Actor\Class$ + ", " + A\X# + "," + A\Y# + "," + A\Z#
For i = 0 To 9
Params$ = Params$ + "," + A\ScriptGlobals$[i]
Next
If A\DeathScript$ <> "" Then ThreadScript(A\DeathScript$, "Main", Handle(Killer), 0, Params$)
; Remove from zone linked list. If the area instance is already
; gone (zone unload race, mid-warp), there's nothing to detach
; from and the existing NextInZone pointers will be dangling
; anyway -- skip rather than crash.
If A\NextInZone <> Null
AInstance.AreaInstance = Object.AreaInstance(A\ServerArea)
If AInstance <> Null
A2.ActorInstance = AInstance\FirstInZone
If A2 = A
AInstance\FirstInZone = A\NextInZone
Else
While A2\NextInZone <> A
A2 = A2\NextInZone
Wend
A2\NextInZone = A\NextInZone
EndIf
EndIf
EndIf
; Remove from spawn point if attached to one. Same Null guard --
; if AInstance is gone, the Spawned counter is already orphaned.
If A\SourceSP > -1
AInstance.AreaInstance = Object.AreaInstance(A\ServerArea)
If AInstance <> Null
AInstance\Spawned[A\SourceSP] = AInstance\Spawned[A\SourceSP] - 1
EndIf
EndIf
FreeActorScripts(A)
FreeActorInstance(A)
EndIf
End Function
; Fires a projectile from one actor at another
Function FireProjectile(P.Projectile, A1.ActorInstance, A2.ActorInstance)
; Check both actors are allowed to engage in combat
If A1\Actor\Aggressiveness = 3 Or A2\Actor\Aggressiveness = 3 Then Return
; Check faction ratings
If A1\FactionRatings[A2\HomeFaction] > 150 Then Return
; Tell all players about the projectile so they can display it. If
; the source's area lookup fails (stale ServerArea after a warp),
; skip the broadcast -- mid-flight projectiles whose source actor
; is in transit just don't get a visual ping; the damage logic
; below still resolves authoritatively on the server.
Pa$ = RCE_StrFromInt$(A1\RuntimeID, 2) + RCE_StrFromInt$(A2\RuntimeID, 2) + RCE_StrFromInt$(P\MeshID, 2)
Pa$ = Pa$ + RCE_StrFromInt$(P\Emitter1TexID, 2) + RCE_StrFromInt$(P\Emitter2TexID, 2) + RCE_StrFromInt$(P\Homing, 1) + RCE_StrFromInt$(P\Speed, 1)
Pa$ = Pa$ + RCE_StrFromInt$(Len(P\Emitter1$), 1) + P\Emitter1$ + P\Emitter2$
AInstance.AreaInstance = Object.AreaInstance(A1\ServerArea)
If AInstance <> Null
A3.ActorInstance = AInstance\FirstInZone
While A3 <> Null
If A3\RNID > 0 Then RCE_Send(Host, A3\RNID, P_Projectile, Pa$, True)
A3 = A3\NextInZone
Wend
EndIf
; Does the projectile hit the target?
ToHit = Rand(100)
If ToHit <= P\HitChance
; Calculate damage
AP = GetArmourLevel(A2\Inventory) + (A2\Resistances[P\DamageType] - 100)
Damage = (P\Damage + Rand(-5, 5)) - AP
If Damage < 1 Then Damage = 1
; Apply damage
A2\Attributes\Value[HealthStat] = A2\Attributes\Value[HealthStat] - Damage
; Tell player(s) if applicable
Pa$ = RCE_StrFromInt$(Damage + 1, 2) + RCE_StrFromInt$(P\DamageType, 1)
If A1\RNID > 0
RCE_Send(Host, A1\RNID, P_AttackActor, "H" + RCE_StrFromInt$(A2\RuntimeID, 2) + Pa$, True)
EndIf
If A2\RNID > 0
RCE_Send(Host, A2\RNID, P_AttackActor, "Y" + RCE_StrFromInt$(A1\RuntimeID, 2) + Pa$, True)
EndIf
; Make attacked actor angry if it's defensive
If A2\RNID = -1
If A2\Actor\Aggressiveness = 1
A2\AITarget = A1
A2\AIMode = AI_Chase
AICallForHelp(A2)
; Or if it's aggressive and has no target...
ElseIf A2\Actor\Aggressiveness = 2 And A2\AITarget = Null
A2\AITarget = A1
A2\AIMode = AI_Chase
AICallForHelp(A2)
EndIf
EndIf
; Death
If A2\Attributes\Value[HealthStat] <= 0 Then KillActor(A2, A1)
EndIf
End Function
; Makes one actor instance attack another
Function ActorAttack(A1.ActorInstance, A2.ActorInstance)
; Bail on null and on already-dead targets. Two attackers landing
; in the same tick previously both saw HP>0, both subtracted, both
; called KillActor -- second call ran against freed (NPC) memory
; for double XP + a use-after-free crash window. Also guards
; against script-spawned attacks racing the death broadcast.
If A1 = Null Or A2 = Null Then Return False
If A2\Attributes\Value[HealthStat] <= 0 Then Return False
; Get distance between the actor instances
XDist# = A1\X# - A2\X#
ZDist# = A1\Z# - A2\Z#
YDist# = (A1\Y# - A2\Y#) / 5.0
Dist# = (XDist# * XDist#) + (ZDist# * ZDist#) + (YDist# * YDist#)
; Check if this is actually a projectile attack
If A1\Inventory\Items[SlotI_Weapon] <> Null
If A1\Inventory\Items[SlotI_Weapon]\Item\WeaponType = W_Ranged
If A1\Inventory\Items[SlotI_Weapon]\ItemHealth > 0
; Fixed function
If CombatFormula <> 4
; In range?
CheckDist# = A1\Inventory\Items[SlotI_Weapon]\Item\Range# + A1\Actor\Radius# + A2\Actor\Radius#
If Dist# > CheckDist# * CheckDist# Then Return False
; Tell other players in the same area. Skip if
; the attacker's area lookup is Null (warp race).
Pa$ = "O" + RCE_StrFromInt$(A1\RuntimeID, 2) + RCE_StrFromInt$(A2\RuntimeID, 2)
AInstance.AreaInstance = Object.AreaInstance(A1\ServerArea)
If AInstance <> Null
A3.ActorInstance = AInstance\FirstInZone
While A3 <> Null
If A3\RNID > 0
If A3 <> A1 And A3 <> A2 Then RCE_Send(Host, A3\RNID, P_AttackActor, Pa$, True)
EndIf
A3 = A3\NextInZone
Wend
EndIf
; Launch projectile
P.Projectile = ProjectileList(A1\Inventory\Items[SlotI_Weapon]\Item\RangedProjectile)
If P <> Null
FireProjectile(P, A1, A2)
A1\LastAttack = MilliSecs()
EndIf
; Attack script
Else
; Check both actors are allowed to engage in combat
If A1\Actor\Aggressiveness = 3 Or A2\Actor\Aggressiveness = 3 Then Return False
; Check faction ratings
If A1\FactionRatings[A2\HomeFaction] > 150 Then Return False
; Store time of attack
A1\LastAttack = MilliSecs()
; Make attacked actor angry if it's defensive
If A2\RNID = -1
If A2\Actor\Aggressiveness = 1
A2\AITarget = A1
A2\AIMode = AI_Chase
; Or if it's aggressive and has no target...
ElseIf A2\Actor\Aggressiveness = 2 And A2\AITarget = Null
A2\AITarget = A1
A2\AIMode = AI_Chase
EndIf
EndIf
ThreadScript("Attack", "Main", Handle(A1), Handle(A2))
EndIf
Return True
Else
If A1\RNID > 0 Then RCE_Send(Host, A1\RNID, P_ChatMessage, Chr$(253) + LanguageString$(LS_WeaponDamaged), True)
Return False
EndIf
EndIf
EndIf
; Check both actors are allowed to engage in combat
If A1\Actor\Aggressiveness = 3 Or A2\Actor\Aggressiveness = 3 Then Return False
; Check faction ratings
If A1\FactionRatings[A2\HomeFaction] > 150 Then Return False
; Check distance is acceptable
CheckDist# = 7.0 + A1\Actor\Radius# + A2\Actor\Radius#
If Dist# > CheckDist# * CheckDist# Then Return False
; Store time of attack
A1\LastAttack = MilliSecs()
; Make attacked actor angry if it's defensive
If A2\RNID = -1
If A2\Actor\Aggressiveness = 1
A2\AITarget = A1
A2\AIMode = AI_Chase
; Or if it's aggressive and has no target...
ElseIf A2\Actor\Aggressiveness = 2 And A2\AITarget = Null
A2\AITarget = A1
A2\AIMode = AI_Chase
EndIf
EndIf
; Calculate damage
; Normal formula
If CombatFormula = 1
AICallForHelp(A2)
; 90% chance to hit
ToHit = Rand(100)
If ToHit > 10
; Initial damage
Strength = A1\Attributes\Value[StrengthStat]
If A1\Inventory\Items[SlotI_Weapon] <> Null
If A1\Inventory\Items[SlotI_Weapon]\ItemHealth > 0
Damage = A1\Inventory\Items[SlotI_Weapon]\Item\WeaponDamage
If Strength < Damage
Damage = Damage - Rand(5, 8)
ElseIf Strength > Damage
Damage = Damage + Rand(5, 8)
Else
Damage = Damage + Rand(-5, 5)
EndIf
DamageType = A1\Inventory\Items[SlotI_Weapon]\Item\WeaponDamageType
Else
Damage = (Strength / 8) + Rand(-5, 5)
DamageType = A1\Actor\DefaultDamageType
EndIf
Else
Damage = (Strength / 8) + Rand(-5, 5)
DamageType = A1\Actor\DefaultDamageType
EndIf
; Critical damage
If Rand(1, 10) = 1
Damage = Damage * 2
If A1\RNID > 0 Then RCE_Send(Host, A1\RNID, P_ChatMessage, Chr$(250) + Chr$(255) + Chr$(225) + Chr$(100) + LanguageString$(LS_CriticalDamage), True)
EndIf
; Armour
AP = GetArmourLevel(A2\Inventory) + (A2\Resistances[DamageType] - 100)
If ToughnessStat > -1 Then AP = AP + (A2\Attributes\Value[ToughnessStat] / 8)
Damage = Damage - AP
; Minimum of 1
If Damage < 1 Then Damage = 1
; Miss!
Else
Damage = -1
EndIf
; No strength bonus or penalty
ElseIf CombatFormula = 2
AICallForHelp(A2)
; 90% chance to hit
ToHit = Rand(100)
If ToHit > 10
; Initial damage
If A1\Inventory\Items[SlotI_Weapon] <> Null
If A1\Inventory\Items[SlotI_Weapon]\ItemHealth > 0
Damage = A1\Inventory\Items[SlotI_Weapon]\Item\WeaponDamage
DamageType = A1\Inventory\Items[SlotI_Weapon]\Item\WeaponDamageType
Else
Damage = (A1\Attributes\Value[StrengthStat] / 8) + Rand(-5, 5)
DamageType = A1\Actor\DefaultDamageType
EndIf
Else
Damage = (A1\Attributes\Value[StrengthStat] / 8) + Rand(-5, 5)
DamageType = A1\Actor\DefaultDamageType
EndIf
; Critical damage
If Rand(1, 10) = 1
Damage = Damage * 2
If A1\RNID > 0 Then RCE_Send(Host, A1\RNID, P_ChatMessage, Chr$(250) + Chr$(255) + Chr$(225) + Chr$(100) + LanguageString$(LS_CriticalDamage), True)
EndIf
; Armour
AP = GetArmourLevel(A2\Inventory) + (A2\Resistances[DamageType] - 100)
If ToughnessStat > -1 Then AP = AP + (A2\Attributes\Value[ToughnessStat] / 8)
Damage = Damage - AP
; Minimum of 1
If Damage < 1 Then Damage = 1
; Miss!
Else
Damage = -1
EndIf
; Multiplied formula
ElseIf CombatFormula = 3
AICallForHelp(A2)
; 90% chance to hit
ToHit = Rand(100)
If ToHit > 10
; Initial damage
Strength = A1\Attributes\Value[StrengthStat]
If A1\Inventory\Items[SlotI_Weapon] <> Null
If A1\Inventory\Items[SlotI_Weapon]\ItemHealth > 0
Damage = A1\Inventory\Items[SlotI_Weapon]\Item\WeaponDamage * Strength
DamageType = A1\Inventory\Items[SlotI_Weapon]\Item\WeaponDamageType
Else
Damage = Strength + Rand(-10, 10)
DamageType = A1\Actor\DefaultDamageType
EndIf
Else
Damage = Strength + Rand(-10, 10)
DamageType = A1\Actor\DefaultDamageType
EndIf
; Critical damage
If Rand(1, 10) = 1
Damage = Damage * 2
If A1\RNID > 0 Then RCE_Send(Host, A1\RNID, P_ChatMessage, Chr$(250) + Chr$(255) + Chr$(225) + Chr$(100) + LanguageString$(LS_CriticalDamage), True)
EndIf
; Armour
AP = GetArmourLevel(A2\Inventory) + (A2\Resistances[DamageType] - 100)
If ToughnessStat > -1 Then AP = AP * A2\Attributes\Value[ToughnessStat] Else AP = AP * AP
Damage = Damage - AP
; Minimum of 1
If Damage < 1 Then Damage = 1
; Miss!
Else
Damage = -1
EndIf
; Scripted
ElseIf CombatFormula = 4
ThreadScript("Attack", "Main", Handle(A1), Handle(A2))
Goto SkipAttackNet
EndIf
; Damage weapon
If WeaponDamage = True
If A1\Inventory\Items[SlotI_Weapon] <> Null
If A1\Inventory\Items[SlotI_Weapon]\ItemHealth > 0
If Rand(1, 5) = 1
A1\Inventory\Items[SlotI_Weapon]\ItemHealth = A1\Inventory\Items[SlotI_Weapon]\ItemHealth - 1
If A1\RNID > 0
Pa$ = RCE_StrFromInt$(SlotI_Weapon, 1) + RCE_StrFromInt$(A1\Inventory\Items[SlotI_Weapon]\ItemHealth, 2)
RCE_Send(Host, A1\RNID, P_ItemHealth, Pa$, True)
EndIf
EndIf
EndIf
EndIf
EndIf
; Damage armour
If ArmourDamage = True
For i = SlotI_Shield To SlotI_Feet
If A1\Inventory\Items[i] <> Null
If A1\Inventory\Items[i]\ItemHealth > 0
If Rand(1, 5) = 1
A1\Inventory\Items[i]\ItemHealth = A1\Inventory\Items[i]\ItemHealth - 1
If A1\RNID > 0
Pa$ = RCE_StrFromInt$(i, 1) + RCE_StrFromInt$(A1\Inventory\Items[i]\ItemHealth, 2)
RCE_Send(Host, A1\RNID, P_ItemHealth, Pa$, True)
EndIf
EndIf
EndIf
EndIf
Next
EndIf
; Apply damage to target actor
If Damage > 0 Then A2\Attributes\Value[HealthStat] = A2\Attributes\Value[HealthStat] - Damage
; Tell player(s) if applicable
Pa$ = RCE_StrFromInt$(Damage + 1, 2) + RCE_StrFromInt$(DamageType, 1)
If A1\RNID > 0
RCE_Send(Host, A1\RNID, P_AttackActor, "H" + RCE_StrFromInt$(A2\RuntimeID, 2) + Pa$, True)
EndIf
If A2\RNID > 0
RCE_Send(Host, A2\RNID, P_AttackActor, "Y" + RCE_StrFromInt$(A1\RuntimeID, 2) + Pa$, True)
EndIf
; Tell other players in the same area. Skip if the attacker's
; area lookup is Null (warp race / freed area).
Pa$ = "O" + RCE_StrFromInt$(A1\RuntimeID, 2) + RCE_StrFromInt$(A2\RuntimeID, 2)
AInstance.AreaInstance = Object.AreaInstance(A1\ServerArea)
If AInstance <> Null
A3.ActorInstance = AInstance\FirstInZone
While A3 <> Null
If A3\RNID > 0
If A3 <> A1 And A3 <> A2 Then RCE_Send(Host, A3\RNID, P_AttackActor, Pa$, True)
EndIf
A3 = A3\NextInZone
Wend
EndIf
.SkipAttackNet
; If target was a player with pets, make pets attack too.
; Walk A1's FirstSlave chain instead of every ActorInstance.
; Local-name is `PetCur` because A3 is implicit-declared at
; function scope by a `For A3.ActorInstance = Each` earlier in
; this function (BlitzForge Local + For-Each collision -- see
; the feedback_blitz_local_for_each_collision memory).
If A1\RNID > 0
If A1\NumberOfSlaves > 0
Local PetCur.ActorInstance = A1\FirstSlave
While PetCur <> Null
If PetCur\Actor\Aggressiveness < 3 And PetCur\AITarget = Null
PetCur\AITarget = A2
PetCur\AIMode = AI_PetChase
EndIf
PetCur = PetCur\NextSlave
Wend
EndIf
EndIf
; Death
If A2\Attributes\Value[HealthStat] <= 0 Then KillActor(A2, A1)
Return True
End Function
; Updates position, etc. of all actor instances
Function UpdateActorInstances(Broadcast)
; Update actor effects.
;
; After-cursor walk: the body Deletes AE in two branches (owner gone,
; effect time expired). Blitz3D's For-Each advances via the deleted
; element's "next" pointer, so the original `For AE = Each ActorEffect
; / Delete AE / Next` shape intermittently skipped effects or crashed
; on bursts of simultaneous expirations. Documented in CLAUDE.md
; ("Iterator-during-iteration hazards", #247).
T = MilliSecs()
Local AE.ActorEffect = First ActorEffect
Local AENext.ActorEffect = Null
While AE <> Null
AENext = After AE
; Owner has gone
If AE\Owner = Null
Delete AE\Attributes
Delete AE
; Owner still alive and online
ElseIf AE\Owner\RNID <> 0
; Remove effect when time is up
If T - AE\CreatedTime > AE\Length
If AE\Length > 0
; Tell client if applicable
If AE\Owner\RNID > 0
Pa$ = RCE_StrFromInt$(Handle(AE), 4)
For i = 0 To 39
Pa$ = Pa$ + RCE_StrFromInt$(AE\Attributes\Value[i], 4)
Next
RCE_Send(Host, AE\Owner\RNID, P_ActorEffect, "R" + Pa$, True)
EndIf
; Remove effect
For i = 0 To 39
AE\Owner\Attributes\Value[i] = AE\Owner\Attributes\Value[i] - AE\Attributes\Value[i]
Next
Delete AE\Attributes
Delete AE
EndIf
EndIf
EndIf
AE = AENext
Wend
; Recharging this frame?
Recharge = False
If MilliSecs() - LastSpellRecharge > 100
Recharge = True
LastSpellRecharge = MilliSecs()
EndIf
For AI.ActorInstance = Each ActorInstance
If AI\RuntimeID > -1
AInstance.AreaInstance = Object.AreaInstance(AI\ServerArea)
; If the actor's area lookup fails (stale ServerArea, mid-warp,
; or freed zone), skip this entire per-tick update -- the body
; below derefs AInstance\Area for waypoint movement, water
; damage, AI aggression, and the broadcast loops. Each of those
; was a one-tick crash; rather than guard each site individually
; the whole pass is gated on AInstance. The actor's position,
; HP, and other intrinsic state are still preserved for when
; SetArea re-establishes the zone link.
If AInstance <> Null
; Recharge spells. SpellCharge is now uniformly indexed by
; spell ID (matches the unified P_SpellUpdate "F" handler in
; ServerNet.bb). The previous split (memorise-slot 0..9 vs
; known-spell-index 0..999) let a player double-cast by
; toggling RequireMemorise or re-memorising into a different
; slot.
If Recharge = True
For i = 0 To 999
If AI\SpellCharge[i] > 0 Then AI\SpellCharge[i] = AI\SpellCharge[i] - 100
Next
EndIf
; Move (except mounts). Guard divide-by-zero on SpeedStat
; Maximum: a misconfigured actor template (or a script that
; zeroed the cap mid-session) would otherwise crash the
; per-tick movement update for every actor in the world.
; Fall back to a full-speed ratio (1.0) so the actor still
; moves; speed-tuning content is a softer failure mode.
If AI\Rider = Null
If AI\Mount = Null
If AI\Attributes\Maximum[SpeedStat] > 0
Speed# = 1.5 * (Float#(AI\Attributes\Value[SpeedStat]) / Float#(AI\Attributes\Maximum[SpeedStat]))
Else
Speed# = 1.5
EndIf
Else
If AI\Mount\Attributes\Maximum[SpeedStat] > 0
Speed# = 1.5 * (Float#(AI\Mount\Attributes\Value[SpeedStat]) / Float#(AI\Mount\Attributes\Maximum[SpeedStat]))
Else
Speed# = 1.5
EndIf
EndIf
If AI\WalkingBackward = True Then Speed# = Speed# / 2.0
If AI\IsRunning = True
Allowed = True
If EnergyStat > -1 And AI\Mount = Null
AI\Attributes\Value[EnergyStat] = AI\Attributes\Value[EnergyStat] - 1
If AI\Attributes\Value[EnergyStat] <= 0
Allowed = False
AI\Attributes\Value[EnergyStat] = 0
AI\IsRunning = False
EndIf
EndIf
If Allowed = True Then Speed# = Speed# * 2.0
EndIf
XDist# = AI\DestX# - AI\X#
ZDist# = AI\DestZ# - AI\Z#
If Abs(XDist#) > 0.5 Or Abs(ZDist#) > 0.5
AI\X# = AI\X# + ((XDist# / (Abs(XDist#) + Abs(ZDist#))) * Speed#)
AI\Z# = AI\Z# + ((ZDist# / (Abs(XDist#) + Abs(ZDist#))) * Speed#)
EndIf
; Mounts stay with their rider
Else
AI\X# = AI\Rider\X#
AI\Y# = AI\Rider\Y#
AI\Z# = AI\Rider\Z#
EndIf
; Underwater damage. Walks the per-Area chain off
; AInstance\Area\FirstWater (built in ServerLoadArea above)
; rather than the global `For Each ServerWater` + filter on
; SW\Area. Old: O(actors * total_water_in_all_loaded_areas)
; per tick. New: O(actors * waters_in_this_actor's_area),
; typically 5-10 per area vs potentially 50+ across all
; loaded zones. Behaviour unchanged -- same hit-test, same
; early Exit on first match.
If AI\Actor\Environment <> Environment_Swim
Underwater = 0
Local SW.ServerWater = AInstance\Area\FirstWater
While SW <> Null
If AI\Y# < SW\Y# + 0.5
If AI\X# > SW\X# And AI\X# < SW\X# + SW\Width#
If AI\Z# > SW\Z# And AI\Z# < SW\Z# + SW\Depth#
If AI\Underwater = 0 Then AI\Underwater = MilliSecs()
Underwater = Handle(SW)
DistUnder# = SW\Y# - AI\Y#
Exit
EndIf
EndIf
EndIf
SW = SW\NextWater
Wend
If Underwater = 0
AI\Underwater = 0
; Restore breath
If BreathStat > -1
If AI\Attributes\Value[BreathStat] < AI\Attributes\Maximum[BreathStat] And Rand(1, 10) = 1
AI\Attributes\Value[BreathStat] = AI\Attributes\Value[BreathStat] + 1
If AI\RNID > 0
Pa$ = RCE_StrFromInt$(AI\RuntimeID, 2) + RCE_StrFromInt$(BreathStat, 1) + RCE_StrFromInt$(AI\Attributes\Value[BreathStat], 2)
RCE_Send(Host, AI\RNID, P_StatUpdate, "A" + Pa$, True)
EndIf
EndIf
EndIf
ElseIf MilliSecs() - AI\Underwater >= 1000
AI\Underwater = AI\Underwater + 1000
SW = Object.ServerWater(Underwater)
; Stale-handle Null discipline (CLAUDE.md): the
; initial scan above captured `Underwater = Handle(SW)`
; for a live ServerWater, but >= 1 second has elapsed
; before this branch runs. If the owning Area was
; ServerUnloadArea'd or the water was explicitly
; Deleted in that window, Object.ServerWater returns
; Null, and the unguarded `SW\Damage` deref below was
; a one-frame server-crash. Breath-loss only needs
; AI state and runs even on stale water (it ticks
; per-second-underwater regardless); damage requires
; SW field reads and is the part that must skip.
; Remove breath, or health if none left
If BreathStat > -1 And DistUnder# > 2.0
AI\Attributes\Value[BreathStat] = AI\Attributes\Value[BreathStat] - 1
If AI\Attributes\Value[BreathStat] < 0
AI\Attributes\Value[BreathStat] = 0
UpdateAttribute(AI, HealthStat, AI\Attributes\Value[HealthStat] - 1)
If AI\Attributes\Value[HealthStat] <= 0
AI\Attributes\Value[HealthStat] = 0
; AI is the current For-Each iterator — defer the
; actual KillActor (which FreeActorInstance's an AI)
; until after the loop. See DeferKillActor /
; ProcessPendingKills.
DeferKillActor(AI, Null)
EndIf
EndIf
If AI\RNID > 0
Pa$ = RCE_StrFromInt$(AI\RuntimeID, 2) + RCE_StrFromInt$(BreathStat, 1) + RCE_StrFromInt$(AI\Attributes\Value[BreathStat], 2)
RCE_Send(Host, AI\RNID, P_StatUpdate, "A" + Pa$, True)
EndIf
EndIf
; Water damage -- requires a live ServerWater. If SW
; is Null (water freed during the 1s breath window),
; skip this block; breath loss above already ran and
; the next tick will either re-pick a new water or
; clear AI\Underwater via the no-hit path above.
If SW <> Null And SW\Damage > 0
Damage = SW\Damage - (AI\Resistances[SW\DamageType] - 100)
If Damage < 1 Then Damage = 1
UpdateAttribute(AI, HealthStat, AI\Attributes\Value[HealthStat] - Damage)
If AI\Attributes\Value[HealthStat] <= 0
AI\Attributes\Value[HealthStat] = 0
; Same iterator-during-iteration hazard — defer.
DeferKillActor(AI, Null)
EndIf
EndIf
EndIf
Else
AI\Underwater = 1
EndIf
; Update AI
If AI\RNID = -1
AInstance.AreaInstance = Object.AreaInstance(AI\ServerArea)
; Wait mode
If AI\AIMode = AI_Wait
; Look for targets now and then
If Rand(1, 10) = 1 Then AILookForTargets(AI)
; Patrol mode
ElseIf AI\AIMode = AI_Patrol Or AI\AIMode = AI_Run
; Arrived - choose next waypoint
If XDist# <= 2.0 And ZDist# <= 2.0
; Set running state
If AI\AIMode = AI_Run
AI\IsRunning = True
Else
AI\IsRunning = False
EndIf
; Find auto-movement range if there is one
SpawnRange# = 0.0
For i = 0 To 999
If AInstance\Area\SpawnWaypoint[i] = AI\CurrentWaypoint
SpawnRange# = AInstance\Area\SpawnRange#[i]
Exit
EndIf
Next
; Auto-move within an area
If SpawnRange# >= 5.0
AI\DestX# = AInstance\Area\WaypointX#[AI\CurrentWaypoint] + Rnd#(-SpawnRange#, SpawnRange#)
AI\DestZ# = AInstance\Area\WaypointZ#[AI\CurrentWaypoint] + Rnd#(-SpawnRange#, SpawnRange#)
; Follow waypoints
Else
AI\Y# = AInstance\Area\WaypointY#[AI\CurrentWaypoint] + Rnd#(-1.5, 1.5)
AI\OldX# = AInstance\Area\WaypointX#[AI\CurrentWaypoint]
AI\OldZ# = AInstance\Area\WaypointZ#[AI\CurrentWaypoint]
; WaypointX/Y/Z are Field[1999]. Next/PrevWaypoint
; are ReadShort at load (-32768..32767); the
; original runtime guard only rejected `> 1999`
; so a negative slot bypassed the check and
; Field-OOB'd the destination read below.
; Treat anything outside 0..1999 as "no next
; waypoint" by setting to 9999 (above the upper
; bound, which the runtime already uses as
; "give up and Wait").
If Rand(1, 2) = 1
NextWP = AInstance\Area\NextWaypointA[AI\CurrentWaypoint]
If NextWP < 0 Or NextWP > 1999 Then NextWP = AInstance\Area\NextWaypointB[AI\CurrentWaypoint]
Else
NextWP = AInstance\Area\NextWaypointB[AI\CurrentWaypoint]
If NextWP < 0 Or NextWP > 1999 Then NextWP = AInstance\Area\NextWaypointA[AI\CurrentWaypoint]
EndIf
If NextWP < 0 Or NextWP > 1999 Then NextWP = AInstance\Area\PrevWaypoint[AI\CurrentWaypoint]
If NextWP < 0 Or NextWP > 1999
AI\AIMode = AI_Wait
Else
AI\DestX# = AInstance\Area\WaypointX#[NextWP] + Rnd#(-2.0, 2.0)
AI\DestZ# = AInstance\Area\WaypointZ#[NextWP] + Rnd#(-2.0, 2.0)
AI\CurrentWaypoint = NextWP
; Waypoint pause
If AInstance\Area\WaypointPause[NextWP] > 0
AI\AIMode = AI_PatrolPause
; I have decided to borrow an unrelated field of the actor instance
; type, used only by the client, to hold the time at which the actor
; reached the waypoint. This is to avoid the needless waste of memory
; when the server has many thousands of active actors caused by adding
; another field especially for this. The only other time this field
; will be referenced by the server is below in the code for the
; AI_PatrolPause mode.
AI\FootstepPlayedThisCycle = MilliSecs()
EndIf
EndIf
EndIf
EndIf
; Look for targets now and then
If Rand(1, 10) = 1 Then AILookForTargets(AI)
; Paused while on patrol mode
ElseIf AI\AIMode = AI_PatrolPause
If MilliSecs() - AI\FootstepPlayedThisCycle >= AInstance\Area\WaypointPause[AI\CurrentWaypoint] * 1000
AI\AIMode = AI_Patrol
Else
; Look for targets now and then
If Rand(1, 10) = 1 Then AILookForTargets(AI)
EndIf
; Attack mode
ElseIf AI\AIMode = AI_Chase
; Target dead
If AI\AITarget <> Null
If AI\AITarget\Attributes\Value[HealthStat] <= 0 Then AI\AITarget = Null
EndIf
; Lost target
If AI\AITarget = Null
AI\AIMode = AI_Patrol
AI\DestX# = AInstance\Area\WaypointX#[AI\CurrentWaypoint]
AI\DestZ# = AInstance\Area\WaypointZ#[AI\CurrentWaypoint]
AI\IsRunning = False
; Chase target
Else
; Target left game
If AI\AITarget\RNID = 0 Or AI\AITarget\ServerArea <> AI\ServerArea
AI\AITarget = Null
; Target available - kick its arse!
Else
XDist# = AI\X# - AI\AITarget\X#
ZDist# = AI\Z# - AI\AITarget\Z#
Dist# = (XDist# * XDist#) + (ZDist# * ZDist#)
CheckDist# = 4.0 + AI\Actor\Radius# + AI\AITarget\Actor\Radius#
If Dist# > CheckDist# * CheckDist#
AI\DestX# = AI\AITarget\X#
AI\DestZ# = AI\AITarget\Z#
AI\IsRunning = True
Else
AI\DestX# = AI\X#
AI\DestZ# = AI\Z#
AI\IsRunning = False
; Attempt to hit target
If MilliSecs() - AI\LastAttack >= CombatDelay
Result = ActorAttack(AI, AI\AITarget)
If Result = True Then AI\DestX# = AI\X# : AI\DestZ# = AI\Z#
EndIf
EndIf
EndIf
EndIf
; Orphaned pet: its leader was freed without resetting AIMode.
; FreeActorInstance's orphan loop and BVM_SETLEADER reset it at
; the source; this is the tick-side backstop so a missed future
; orphaning path can't deref a Null AI\Leader in the pet branches
; below (the AI_Chase branch already follows this Null discipline).
; Operands are plain reads/compares -- no deref through Leader --
; so the non-short-circuit And/Or is safe here.
ElseIf (AI\AIMode = AI_Pet Or AI\AIMode = AI_PetChase) And AI\Leader = Null
AI\AIMode = AI_Wait
AI\AITarget = Null
; Pet AI
ElseIf AI\AIMode = AI_Pet
; Move towards leader's position
AI\DestX# = AI\Leader\X#
AI\DestZ# = AI\Leader\Z#
AI\Y# = AI\Y# + ((AI\Leader\Y# - AI\Y#) / 50.0)
AI\IsRunning = AI\Leader\IsRunning
; When close enough to leader, stop moving
XDist# = AI\X# - AI\Leader\X#
ZDist# = AI\Z# - AI\Leader\Z#
Dist# = (XDist# * XDist#) + (ZDist# * ZDist#)
CheckDist# = 5.0 + AI\Actor\Radius# + AI\Leader\Actor\Radius#
If Dist# <= CheckDist# * CheckDist#
AI\DestX# = AI\X#
AI\DestZ# = AI\Z#
EndIf
; Keep updated with leader's target
If AI\Actor\Aggressiveness < 3
If AI\Leader\AITarget <> Null
XDist# = AI\Leader\AITarget\X# - AI\Leader\X#
ZDist# = AI\Leader\AITarget\Z# - AI\Leader\Z#
Dist# = (XDist# * XDist#) + (ZDist# * ZDist#)
If Dist# <= 1000.0
AI\AITarget = AI\Leader\AITarget
AI\AIMode = AI_PetChase
EndIf
EndIf
EndIf
; Pet AI attack mode
ElseIf AI\AIMode = AI_PetChase