File size: 15,379 Bytes
b6a38d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
MapVar("MoraleEffectCooldown", {})
MapVar("MoraleModifierCooldown", {})
MapVar("MoraleGlobalCooldown", 0)
MapVar("MoraleActionThread", false)

local modifier_cooldowns = {
	-- [id] = number of turns
	-- 0 = cannot happen during current turn
	-- 1 = cannot happen during current and next turns
}

MoraleLevelName = {
	[-3] = T(738790082445, --[[MoraleLevelName: Abysmal]] "<error>Abysmal</error>"),
	[-2] = T(628671961455, --[[MoraleLevelName: Very Low]] "<error>Very Low</error>"),
	[-1] = T(966377690475, --[[MoraleLevelName: Low]] "<error>Low</error>"),
	[0] = T(274341293889, --[[MoraleLevelName: Stable]] "Stable"),
	[1] = T(899829984127, --[[MoraleLevelName: High]] "High"),
	[2] = T(981991901247, --[[MoraleLevelName: Very High]] "Very High"),
	[3] = T(447600477466, --[[MoraleLevelName: Exceptional]] "Exceptional"),
}

MoraleLevelIcon = {
	[-2] = "UI/Hud/morale_very_low.png",
	[-1] = "UI/Hud/morale_low.png",
	[0] = "UI/Hud/morale_normal.png",
	[1] = "UI/Hud/morale_high.png",
	[2] = "UI/Hud/morale_very_high.png",
}

local function GetMoraleEffectTarget(effect, team)
	if effect.AppliedTo == "custom" then
		return effect:GetTargetUnit(team)
	end

	local units
	if effect.AppliedTo == "teammate" then
		units = table.icopy(team.units)
	else
		units = {}
		for _, t in ipairs(g_Teams) do
			if effect.AppliedTo == "ally" and (t == team or t:IsAllySide(team)) then
				table.iappend(units, t.units)
			elseif effect.AppliedTo == "enemy" and t:IsEnemySide(team) then
				table.iappend(units, t.units)
			end			
		end
	end
	
	units = table.ifilter(units, function(idx, unit) return not unit:IsIncapacitated() and unit.species == "Human" and unit:IsAware() end)
	
	if effect.AppliedTo ~= "enemy" then
		--get highest/lowest personal morale in the units table
		local bestMerc
		for _, unit in ipairs(units) do
			if not bestMerc then
				bestMerc = unit
			elseif effect.Activation == "positive" and bestMerc:GetPersonalMorale() < unit:GetPersonalMorale() then
				bestMerc = unit
			elseif effect.Activation == "negative" and bestMerc:GetPersonalMorale() > unit:GetPersonalMorale() then
				bestMerc = unit
			end
		end
		
		--remove from the units table all units with different than the highest/lowest personal morale
		local morale = bestMerc and bestMerc:GetPersonalMorale()
		if morale then
			for idx, unit in ipairs(units) do
				if unit:GetPersonalMorale() ~= morale then
					table.remove(units, idx)
				end
			end
		end
	end
			
	if #units > 0 then
		return table.interaction_rand(units, "Combat")
	end	
end

function GetEnemyPanicTargets(team)
	local ref_unit
	for _, unit in ipairs(team.units) do
		if not unit:IsDead() then
			ref_unit = unit
			break
		end
	end
	if not ref_unit then return end

	local enemies = table.icopy(GetAllEnemyUnits(ref_unit))
	local num_targets  = (team.morale < 2) and 1 or InteractionRandRange(1, 3, "Combat")
	local targets = {}
	while #enemies > 0 and #targets < num_targets do
		local unit, idx = table.interaction_rand(enemies, "Combat")
		targets[#targets + 1] = unit
		table.remove(enemies, idx)
	end
	return enemies
end

function CombatTeam:GetMoraleEffectChance(effect_type, leadership)
	if not leadership then
		leadership = 0
		for _, unit in ipairs(self.units) do
			if not unit:IsIncapacitated() then
				leadership = Max(leadership, unit.Leadership)
			end
		end		
	end
	if effect_type == "positive" then
		return 20 * self.morale * Max(0, leadership - 50) / 50
	end
	assert(effect_type == "negative")
	return Max(0, -20 * self.morale * (50 - Max(0, leadership - 50)) / 50)
end

function CombatTeam:ChangeMorale(delta, event)
	if not g_Combat then return end
	
	assert(self:IsPlayerControlled())
	self.morale = Clamp(self.morale + delta, -2, 2)
	
	if delta > 0 then
		for _, unit in ipairs(self.units) do
			if HasPerk(unit, "Pessimist") then
				local chance = CharacterEffectDefs.Pessimist:ResolveValue("procChance")
				local roll = InteractionRand(100, "Pessimist")
				if roll < chance then
					PlayVoiceResponse(unit, "Pessimist")
					CombatLog("important", T(877663227979, "Pessimist: Morale increase event negated"))
					return
				end
			end
		end
		
		CombatLog("important", T{990449238632, "<em>Morale</em> is improving and is now <em><morale_level></em> (<event>)", morale_level = MoraleLevelName[self.morale], event = event})
	else
		for _, unit in ipairs(self.units) do
			if HasPerk(unit, "Optimist") then
				local chance = CharacterEffectDefs.Optimist:ResolveValue("procChance")
				local roll = InteractionRand(100, "Optimist")
				if roll < chance then
					PlayVoiceResponse(unit, "Optimist")
					CombatLog("important", T(875387191185, "Optimist: Morale decrease event negated"))
					return
				end
			end
		end
				
		CombatLog("important", T{293473420725, "<em>Morale</em> is dropping and is now <em><morale_level></em> (<event>)", morale_level = MoraleLevelName[self.morale], event = Untranslated(event)})
		
		if self.morale <= -2 then
			PlayVoiceResponse(table.rand(self.units), "TacticalLoss")
		end 
	end
	
	if event and modifier_cooldowns[event] then
		MoraleModifierCooldown[event] = g_Combat.current_turn + modifier_cooldowns[event]
	end	
		
	if MoraleGlobalCooldown >= g_Combat.current_turn or #self.units == 0 then
		--return
	end
	
	local leadership = 0
	for _, unit in ipairs(self.units) do
		leadership = Max(leadership, unit.leadership)
	end
	
	-- find eligible effects, trigger one
	local effect_targets = {}
	local eligible_effects = {}
	for id, effect in sorted_pairs(MoraleEffects) do
		local target 
		
		local can_activate
		local chance = self:GetMoraleEffectChance(effect.Activation, leadership)
		if effect.Activation == "positive" then
			can_activate = (delta > 0 and self.morale > 0) and (InteractionRand(100, "Combat") < chance)
		elseif effect.Activation == "negative" then
			can_activate = (delta < 0 and self.morale < 0) and (InteractionRand(100, "Combat") < chance)
		end			
		
		if can_activate and (MoraleEffectCooldown[id] or 0) < g_Combat.current_turn then
			target = GetMoraleEffectTarget(effect, self)
		end
				
		if target then
			effect_targets[id] = target
			eligible_effects[#eligible_effects + 1] = effect
		end
	end
	
	if #eligible_effects > 0 then 
		local effect = table.weighted_rand(eligible_effects, "Weight", InteractionRand(1000000, "PickMoraleEffectSeed"))
		local target = effect_targets[effect.id]
		
		effect:Activate(target)
		
		local cooldown = Max(0, effect.GlobalCooldown) -- limit 1 effect/turn
		MoraleGlobalCooldown = Max(MoraleGlobalCooldown, g_Combat.current_turn + cooldown)
		if effect.Cooldown >= 0 then
			MoraleEffectCooldown[effect.id] = Max(MoraleEffectCooldown[effect.id], g_Combat.current_turn + effect.Cooldown)
		end
	end
	Msg("MoraleChange")
	ObjModified(self)
	ObjModified(Selection)
end

function CombatTeam:GetMoraleLevelAndEffectsText()
	local morale = self.morale
	local effects_text = ""
	local pchance = self:GetMoraleEffectChance("positive")
	local nchance = self:GetMoraleEffectChance("negative")
	if morale == 0 then
		effects_text = T{872793014384, "  Positive effect chance: <percent(num1)><newline>", num1 = pchance}
	elseif morale > 0 then
		effects_text = T{891625701767, "  <ap(num)> on start of turn<newline>  Positive effect chance: <percent(num1)>", num = morale * const.Scale.AP, num1 = pchance}
	else
		effects_text = T{295409017319, "  <ap(num)> on start of turn<newline>  Negative effect chance: <percent(num1)>", num = morale * const.Scale.AP, num1 = nchance}
	end
	return T{834924000608, "Team Morale: <level><newline><effects><newline><newline>The morale level of each merc is influenced by Team Morale and various individual factors. Morale <em>modifies AP</em> and can trigger positive and negative effects based on the <em>highest Leadership</em> among the mercs.", level = MoraleLevelName[morale] or morale, effects = effects_text}
end


-- called when a potentially morale-altering event happens
function MoraleModifierEvent(event, ...)
	if not g_Combat or (MoraleModifierCooldown[event] or 0 >= g_Combat.current_turn) then
		return
	end
	
	if event == "LieutenantDefeated" then
		for _, team in ipairs(g_Teams) do
			if team:IsPlayerControlled() and #team.units > 0 then
				local unit = select(1, ...)
				team:ChangeMorale(1, T{626055315388, "<villain_name> defeated",villain_name = unit:GetDisplayName()})
			end
		end
	elseif event == "UnitDied" then
		local unit = select(1, ...)
		if unit.team:IsPlayerControlled() then
			for _, merc in ipairs(unit.team.units) do
				if merc ~= unit and unit.team and table.find(merc.Likes, unit.unitdatadef_id) then
					unit.team:ChangeMorale(-1, T{660013290366, "<merc_name> died",merc_name = unit:GetDisplayName()})
					break
				end
			end
		end
	elseif event == "UnitDowned" or event == "BecomeDisliked" then
		local unit = select(1, ...)
		if unit.team and unit.team:IsPlayerControlled() then
			local negative_text
			if event == "UnitDowned" then
				negative_text = T{904916427918, "<merc_name> is Downed",merc_name = unit:GetDisplayName()}
			else
				local disliked_unit = select(2, ...)
				negative_text = T{471976678995, "<merc_name> dislikes <disliked_merc>",merc_name = unit:GetDisplayName(), disliked_merc = disliked_unit:GetDisplayName()}
			end
			unit.team:ChangeMorale(-1, negative_text)
		end
	elseif event == "SpectacularKill" or event == "BecomeLiked" then
		local unit = select(1, ...)
		if unit.team and unit.team:IsPlayerControlled() then
			local positive_text
			if event == "SpectacularKill" then
				positive_text = T(784410614255, "Good kill")
			else
				local liked_unit = select(2, ...)
				positive_text = T{205575546925, "<merc_name> likes <liked_merc>", merc_name = unit:GetDisplayName(), liked_merc = liked_unit:GetDisplayName()}
			end
			unit.team:ChangeMorale(1, positive_text)
		end
	elseif event == "UnitDamaged" then
		local unit = select(1, ...)
		local dmg = select(2, ...)
		if unit.team and unit.team:IsPlayerControlled() and dmg >= 30 then
			unit.team:ChangeMorale(-1, T{347215662696, "<merc_name> is hurt",merc_name = unit:GetDisplayName()})
		end
	end
end

function TFormat.UnitDisplayAlias(ctx)
	local unit = ctx and ctx[1]
	if unit then
		local enemy = not unit.team.player_team and not unit.team.player_ally
		local ally = unit.team.player_ally
		local merc = IsMerc(unit)
		local count = #ctx
		
		if merc then
			return count > 1 and T{849089434818, "<num> Mercs", num = count} or unit.Nick or unit.Name or T(521796235967, "Merc")
		elseif ally then
			return count > 1 and T{237316267844, "<num> Allies", num = count} or unit.Nick or unit.Name or T(307626260917, "Ally")
		elseif enemy then
			return count > 1 and T{392526468031, "<num> Enemies", num = count} or unit.Nick or unit.Name or T(616781107824, "Enemy")
		end
	end
end

function UnitsDisplayAlias(units)
	local unit = IsValid(units) and units or (units and units[1])
	if not unit then return T(146939580323, "Someone") end
	
	return TFormat.UnitDisplayAlias(units)
end

function ExecMoraleActions()
	local team = g_Teams[g_CurrentTeam]
	
	-- activate morale-related AI-control (panic, berserk) on eligible units, if any
	local panicked = table.ifilter(team.units, function(idx, unit) return unit:HasStatusEffect("Panicked") and not unit:IsIncapacitated() and unit.ActionPoints > 0 end)
	local controller
	local lastUnit
	if #panicked > 0 then
		local name = UnitsDisplayAlias(panicked)
		local notification = (team.player_team or team.player_ally) and "allyMoraleEffect" or "enemyMoraleEffect"
		local text = #panicked == 1 and T{561380303080, "<name> is panicked", name = name} or T{164773003084, "<name> are panicked", name = name}
		controller = CreateAIExecutionController({override_notification = notification, override_notification_text = text})
		controller:Execute(panicked)				
		
		for _, unit in ipairs(panicked) do
			unit:RemoveStatusEffect("FreeMove")
			unit.ActionPoints = 0
			lastUnit = unit
			ObjModified(unit)
		end
	end
	
	local berserk = table.ifilter(team.units, function(idx, unit) return unit:HasStatusEffect("Berserk") and not unit:IsIncapacitated() and unit.ActionPoints > 0 end)
	if #berserk > 0 then
		local name = UnitsDisplayAlias(berserk)
		local notification = team.player_team and "allyMoraleEffect" or "enemyMoraleEffect"
		local text = #berserk == 1 and T{455420829781, "<name> is going berserk", name = name} or T{896715224643, "<name> are going berserk", name = name}
		if not controller then
			controller = CreateAIExecutionController({override_notification = notification, override_notification_text = text})
		else
			controller.override_notification = notification
			controller.override_notification_text = text
		end
		controller:Execute(berserk)
		for _, unit in ipairs(berserk) do
			unit:RemoveStatusEffect("FreeMove")
			unit.ActionPoints = 0
			lastUnit = unit
			ObjModified(unit)
		end
	end
	
	if controller then
		HideTacticalNotification("allyMoraleEffect")
		HideTacticalNotification("enemyMoraleEffect")
		controller.restore_camera_obj = lastUnit --with this set, controller will restore camera angle on done and focus this obj
		DoneObject(controller)
		ClearAllCombatBadges()
	end	
end

function ScheduleMoraleActions() -- schedule units from the current team who panicked or went berserk to take their action
	if not IsValidThread(MoraleActionThread) then
		MoraleActionThread = CreateGameTimeThread(ExecMoraleActions)
	end
end

function OnMsg.CombatStart()
	MoraleEffectCooldown = {}
	MoraleModifierCooldown = {}
	MoraleGlobalCooldown = 0
	if IsValidThread(MoraleActionThread) then
		DeleteThread(MoraleActionThread)
		MoraleActionThread = false
	end
end

function OnMsg.EnterSector()
	if not g_Combat then
		for _, team in ipairs(g_Teams) do
			 team.morale = 0
		end
	end
end

function OnMsg.ConflictEnd(sector)
	if gv_CurrentSectorId == sector.Id and not g_Combat then 
		for _, team in ipairs(g_Teams) do
			team.morale = 0
		end
	end
end

MapVar("g_PanickedUnits", {})
MapVar("g_PanicThread", false)

function PanicOutOfSequence(units)
	return CreateGameTimeThread(function(units) -- execution controller will wait for the current combat action to end
		if not units then
			units = g_PanickedUnits
			g_PanickedUnits = {}
		end
		local name = UnitsDisplayAlias(units)
		-- make sure the units have enough AP to act
		for _, unit in ipairs(units) do
			unit.ActionPoints = unit:GetMaxActionPoints()
		end
		
		local notification = --[[self.team.player_team and "allyMoraleEffect" or ]]"enemyMoraleEffect"
		local text = #units == 1 and T{561380303080, "<name> is panicked", name = name} or T{164773003084, "<name> are panicked", name = name}
		local controller = CreateAIExecutionController({override_notification = notification, override_notification_text = text})
		SetInGameInterfaceMode("IModeCombatMovement")
		if ActionCameraPlaying then
			RemoveActionCamera(true)
			WaitMsg("ActionCameraRemoved", 5000)
		end
		controller:Execute(units)
	
		for _, unit in ipairs(units) do
			unit:RemoveStatusEffect("FreeMove")
			if not unit.infinite_ap then
				unit.ActionPoints = 0
			end
			ObjModified(unit)
		end
		HideTacticalNotification(notification)
		DoneObject(controller)
		AdjustCombatCamera("reset")
	end, units)
end

function OnMsg.StatusEffectAdded(unit, id)
	if id == "Panicked" and unit.team ~= g_Teams[g_CurrentTeam] then
		g_PanickedUnits[#g_PanickedUnits + 1] = unit
		g_PanicThread = g_PanicThread or PanicOutOfSequence()
	end
end