diff --git a/Doc/DML Documentation.pdf b/Doc/DML Documentation.pdf index f170779..0233912 100644 Binary files a/Doc/DML Documentation.pdf and b/Doc/DML Documentation.pdf differ diff --git a/Doc/DML Quick Reference.pdf b/Doc/DML Quick Reference.pdf index 4c76ffc..f53f78e 100644 Binary files a/Doc/DML Quick Reference.pdf and b/Doc/DML Quick Reference.pdf differ diff --git a/modules/cfxOwnedZones (legacy 1.3.0).lua b/modules/cfxOwnedZones (legacy 1.3.0).lua new file mode 100644 index 0000000..792bdd8 --- /dev/null +++ b/modules/cfxOwnedZones (legacy 1.3.0).lua @@ -0,0 +1,1316 @@ +cfxOwnedZones = {} +cfxOwnedZones.version = "1.3.0" +cfxOwnedZones.verbose = false +cfxOwnedZones.announcer = true +cfxOwnedZones.name = "cfxOwnedZones" +--[[-- VERSION HISTORY + +1.0.3 - added getNearestFriendlyZone + - added getNearestOwnedZone + - added hasOwnedZones + - added getNearestOwnedZoneToPoint +1.0.4 - changed addOwnedZone code to use cfxZone.getCoalitionFromZoneProperty + - changed to use dcsCommon.coalition2county + - changed to using correct coalition for spawing attackers and defenders +1.0.5 - repairing defenders switches to country instead coalition when calling createGroundUnitsInZoneForCoalition -- fixed +1.0.6 - removed call to conqTemplate + - verified that pause will also apply to init + - unbeatable zones + - untargetable zones + - hidden attribute +1.0.7 - optional cfxGroundTroops module, error message when attackers + - support of 'none' type string to indicate no attackers/defenders + - updated property access + - module check + - cfxOwnedZones.usesDefenders(aZone) + - verifyZone +1.0.8 - repairDefenders trims types to allow blanks in + type separator +1.1.0 - config zone + - bang! support r, b, n capture + - defaulting attackDelta to 10 instead of radius + - verbose code for spawning + - verbose code for state transition + - attackers have (A) in name, defenders (D) + - exit createDefenders if no troops + - exit createAttackers if no troops + - usesAttackers/usesDefenders checks for neutral ownership + - verbose state change + - nearestZone supports moving zones + - remove exiting defenders from zone after cap to avoid + shocked state + - announcer +1.1.1 - conq+1 flag +1.1.2 - corrected type bug in zoneConquered +1.2.0 - support for persistence + - conq+1 --> conquered! + - no cfxGroundTroop bug (no delay) +1.2.1 - fix in load to correctly re-establish all attackers for subsequent save +1.2.2 - redCap! and blueCap! +1.2.3 - fix for persistence bug when not using conquered flag +1.2.4 - pause? and activate? inputs +1.3.0 - new update method + - new fastEval option in config + - new numCap option in config + - new numKeep option in config + - new easyContest option in config + - new logic to keep and lose zones. controlled with numKeep and numCap. + - winSound + - loseSound + - redLost! zone output + - blueLost! zone output + - ownedBy direct zone output + - neutral! zone output + +--]]-- +cfxOwnedZones.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + -- pretty stupid to check for this since we + -- need common to invoke the check, but anyway + "cfxZones", -- Zones, of course + "cfxCommander", -- to make troops do stuff +-- "cfxGroundTroops", -- optional, used for attackers only +} + +cfxOwnedZones.zones = {} +cfxOwnedZones.ups = 1 +cfxOwnedZones.initialized = false +cfxOwnedZones.defendingTime = 100 -- 100 seconds until new defenders are produced +cfxOwnedZones.attackingTime = 300 -- 300 seconds until new attackers are produced +cfxOwnedZones.shockTime = 200 -- 200 -- 'shocked' period of inactivity +cfxOwnedZones.repairTime = 200 -- 200 -- time until we raplace one lost unit, also repairs all other units to 100% + +-- persistence: all attackers we ever sent out. +-- is regularly verified and cut to size +cfxOwnedZones.spawnedAttackers = {} + +-- owned zones is a module that managers 'conquerable' zones and keeps a +-- record of who owns the zone +-- based on some simple rules that are regularly checked + +-- +-- *** EXTENTDS ZONES ***, so compatible with cfxZones, pilotSafe (limited airframes), may conflict with FARPZones +-- + +-- owned zones are identified by the 'owner' property. It can be initially set to nothing (default), NEUTRAL, RED or BLUE + +-- when a zone changes hands, a callback can be installed to be told of that fact +-- callback has the format (zone, newOwner, formerOwner) with zone being the Zone, and new owner and former owners +cfxOwnedZones.conqueredCallbacks = {} + +-- +-- zone attributes when owned +-- owner: coalition that owns the zone +-- status: FSM for spawning +-- defendersRED/BLUE - coma separated type string for the group to spawn on defense cycle completion +-- attackersRED/BLUE - as above for attack cycle. +-- timeStamp - time when zone switched into current state +-- spawnRadius - overrides zone's radius when placing defenders. can be use to place defenders inside or outside zone itself +-- formation - defender's formation +-- attackFormation - attackers formation +-- attackRadius - radius of circle in which attackers are spawned. informs formation +-- attackDelta - polar coord: r from zone center where attackers are spawned +-- attackPhi - polar degrees where attackers are to be spawned +-- paused - will not spawn. default is false +-- unbeatable - can't be conquered by other side. default is false +-- untargetable - will not be targeted by either side. make unbeatable +-- owned zones untargetable, or they'll become a troop magnet for +-- zoneAttackers +-- hidden - if set (default no), it no markings on the map +-- +-- to create an owned zone that can't be conquered and does nothing +-- add the following properties to a zone +-- owner = , paused = true, unbeatable = true + +-- +-- callback handling +-- + +function cfxOwnedZones.addCallBack(conqCallback) + local cb = {} + cb.callback = conqCallback -- we use this so we can add more data later + cfxOwnedZones.conqueredCallbacks[conqCallback] = cb + +end + +function cfxOwnedZones.invokeConqueredCallbacks(aZone, newOwner, lastOwner) + for key, cb in pairs (cfxOwnedZones.conqueredCallbacks) do + cb.aZone = aZone -- set these up for if we need them later + cb.newOwner = newOwner + cb.lastOwner = lastOwner + -- invoke callback + cb.callback(aZone, newOwner, lastOwner) + end +end + +function cfxOwnedZones.side2name(theSide) + if theSide == 1 then return "REDFORCE" end + if theSide == 2 then return "BLUEFORCE" end + return "Neutral" +end + +function cfxOwnedZones.conqTemplate(aZone, newOwner, lastOwner) + if true then return end -- do not output + + if lastOwner == 0 then + trigger.action.outText(cfxOwnedZones.side2name(newOwner) .. " have taken possession zone " .. aZone.name, 30) + return + end + + trigger.action.outText("Zone " .. aZone.name .. " was taken by ".. cfxOwnedZones.side2name(newOwner) .. " from " .. cfxOwnedZones.side2name(lastOwner), 30) +end + +-- +-- M I S C +-- + +function cfxOwnedZones.drawZoneInMap(aZone) + -- will save markID in zone's markID + if aZone.markID then + trigger.action.removeMark(aZone.markID) + end + if aZone.hidden then return end + + local lineColor = {1.0, 0, 0, 1.0} -- red + local fillColor = {1.0, 0, 0, 0.2} -- red + local owner = cfxOwnedZones.getOwnerForZone(aZone) + if owner == 2 then + lineColor = {0.0, 0, 1.0, 1.0} + fillColor = {0.0, 0, 1.0, 0.2} + elseif owner == 0 then + lineColor = {0.8, 0.8, 0.8, 1.0} + fillColor = {0.8, 0.8, 0.8, 0.2} + end + + local theShape = 2 -- circle + local markID = dcsCommon.numberUUID() + + trigger.action.circleToAll(-1, markID, aZone.point, aZone.radius, lineColor, fillColor, 1, true, "") + aZone.markID = markID + +end + +function cfxOwnedZones.getOwnedZoneByName(zName) + for zKey, theZone in pairs (cfxOwnedZones.zones) do + if theZone.name == zName then return theZone end + end + return nil +end + +function cfxOwnedZones.addOwnedZone(aZone) + local owner = cfxZones.getCoalitionFromZoneProperty(aZone, "owner", 0) -- is already readm read it again + + aZone.owner = owner -- add this attribute to zone + + -- now init all other owned zone properties + aZone.state = "init" + aZone.timeStamp = timer.getTime() + --aZone.defendersRED = "Soldier M4,Soldier M4,Soldier M4,Soldier M4,Soldier M4" -- vehicles allocated to defend when red + + aZone.defendersRED = cfxZones.getStringFromZoneProperty(aZone, "defendersRED", "none") + aZone.defendersBLUE = cfxZones.getStringFromZoneProperty(aZone, "defendersBLUE", "none") + + aZone.attackersRED = cfxZones.getStringFromZoneProperty(aZone, "attackersRED", "none") + aZone.attackersBLUE = cfxZones.getStringFromZoneProperty(aZone, "attackersBLUE", "none") + + local formation = cfxZones.getZoneProperty(aZone, "formation") + if not formation then formation = "circle_out" end + aZone.formation = formation + formation = cfxZones.getZoneProperty(aZone, "attackFormation") + if not formation then formation = "circle_out" end + aZone.attackFormation = formation + local spawnRadius = cfxZones.getNumberFromZoneProperty(aZone, "spawnRadius", aZone.radius-5) -- "-5" so they remaininside radius + + aZone.spawnRadius = spawnRadius + + local attackRadius = cfxZones.getNumberFromZoneProperty(aZone, "attackRadius", aZone.radius) + aZone.attackRadius = attackRadius + local attackDelta = cfxZones.getNumberFromZoneProperty(aZone, "attackDelta", 10) -- aZone.radius) + aZone.attackDelta = attackDelta + local attackPhi = cfxZones.getNumberFromZoneProperty(aZone, "attackPhi", 0) + aZone.attackPhi = attackPhi + + local paused = cfxZones.getBoolFromZoneProperty(aZone, "paused", false) + aZone.paused = paused + + if cfxZones.hasProperty(aZone, "conquered!") then + aZone.conqueredFlag = cfxZones.getStringFromZoneProperty(aZone, "conquered!", "*") + elseif cfxZones.hasProperty(aZone, "conq+1") then + aZone.conqueredFlag = cfxZones.getStringFromZoneProperty(aZone, "conq+1", "*") + end + + if cfxZones.hasProperty(aZone, "redCap!") then + aZone.redCap = cfxZones.getStringFromZoneProperty(aZone, "redCap!", "none") + end + + if cfxZones.hasProperty(aZone, "redLost!") then + aZone.redLost = cfxZones.getStringFromZoneProperty(aZone, "redLost!", "none") + end + + if cfxZones.hasProperty(aZone, "blueCap!") then + aZone.blueCap = cfxZones.getStringFromZoneProperty(aZone, "blueCap!", "none") + end + + if cfxZones.hasProperty(aZone, "blueLost!") then + aZone.blueLost = cfxZones.getStringFromZoneProperty(aZone, "blueLost!", "none") + end + + if cfxZones.hasProperty(aZone, "neutral!") then + aZone.neutralCap = cfxZones.getStringFromZoneProperty(aZone, "neutral!", "none") + end + + if cfxZones.hasProperty(aZone, "ownedBy") then + aZone.ownedBy = cfxZones.getStringFromZoneProperty(aZone, "ownedBy", "none") + end + + -- pause? and activate? + if cfxZones.hasProperty(aZone, "pause?") then + aZone.pauseFlag = cfxZones.getStringFromZoneProperty(aZone, "pause?", "none") + aZone.lastPauseValue = trigger.misc.getUserFlag(aZone.pauseFlag) + end + + if cfxZones.hasProperty(aZone, "activate?") then + aZone.activateFlag = cfxZones.getStringFromZoneProperty(aZone, "activate?", "none") + aZone.lastActivateValue = trigger.misc.getUserFlag(aZone.activateFlag) + end + + aZone.ownedTriggerMethod = cfxZones.getStringFromZoneProperty(aZone, "triggerMethod", "change") + if cfxZones.hasProperty(aZone, "ownedTriggerMethod") then + aZone.ownedTriggerMethod = cfxZones.getStringFromZoneProperty(aZone, "ownedTriggerMethod", "change") + end + + + aZone.unbeatable = cfxZones.getBoolFromZoneProperty(aZone, "unbeatable", false) + aZone.untargetable = cfxZones.getBoolFromZoneProperty(aZone, "untargetable", false) + aZone.hidden = cfxZones.getBoolFromZoneProperty(aZone, "hidden", false) + cfxOwnedZones.zones[aZone] = aZone + cfxOwnedZones.drawZoneInMap(aZone) + cfxOwnedZones.verifyZone(aZone) +end + +function cfxOwnedZones.verifyZone(aZone) + -- do some sanity checks + if not cfxGroundTroops and (aZone.attackersRED ~= "none" or aZone.attackersBLUE ~= "none") then + trigger.action.outText("+++owdZ: " .. aZone.name .. " attackers need cfxGroundTroops to function", 30) + end + +end + +function cfxOwnedZones.getOwnerForZone(aZone) + local theZone = cfxOwnedZones.zones[aZone] + if not theZone then return 0 end -- unknown zone, return neutral as default + return theZone.owner +end + +function cfxOwnedZones.getEnemyZonesFor(aCoalition) + local enemyZones = {} + local ourEnemy = dcsCommon.getEnemyCoalitionFor(aCoalition) + for zKey, aZone in pairs(cfxOwnedZones.zones) do + if aZone.owner == ourEnemy then -- only check enemy owned zones + -- note: will include untargetable zones + table.insert(enemyZones, aZone) + end + end + return enemyZones +end + +function cfxOwnedZones.getNearestOwnedZoneToPoint(aPoint) + local shortestDist = math.huge + local closestZone = nil + + for zKey, aZone in pairs(cfxOwnedZones.zones) do + local zPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and + currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + + return closestZone, shortestDist +end + +function cfxOwnedZones.getNearestOwnedZone(theZone) + local shortestDist = math.huge + local closestZone = nil + local aPoint = cfxZones.getPoint(theZone) + for zKey, aZone in pairs(cfxOwnedZones.zones) do + local zPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + + return closestZone, shortestDist +end + +function cfxOwnedZones.getNearestEnemyOwnedZone(theZone, targetNeutral) + if not targetNeutral then targetNeutral = false else targetNeutral = true end + local shortestDist = math.huge + local closestZone = nil + local ourEnemy = dcsCommon.getEnemyCoalitionFor(theZone.owner) + if not ourEnemy then return nil end -- we called for a neutral zone. they have no enemies + local zPoint = cfxZones.getPoint(theZone) + + for zKey, aZone in pairs(cfxOwnedZones.zones) do + if targetNeutral then + -- return all zones that do not belong to us + if aZone.owner ~= theZone.owner then + local aPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(aPoint, zPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + else + -- return zones that are taken by the Enenmy + if aZone.owner == ourEnemy then -- only check own zones + local aPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + end + end + + return closestZone, shortestDist +end + +function cfxOwnedZones.getNearestFriendlyZone(theZone, targetNeutral) + if not targetNeutral then targetNeutral = false else targetNeutral = true end + local shortestDist = math.huge + local closestZone = nil + local ourEnemy = dcsCommon.getEnemyCoalitionFor(theZone.owner) + if not ourEnemy then return nil end -- we called for a neutral zone. they have no enemies nor friends, all zones would be legal. + local zPoint = cfxZones.getPoint(theZone) + for zKey, aZone in pairs(cfxOwnedZones.zones) do + if targetNeutral then + -- target all zones that do not belong to the enemy + if aZone.owner ~= ourEnemy then + local aPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + else + -- only target zones that are taken by us + if aZone.owner == theZone.owner then -- only check own zones + local aPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + end + end + + return closestZone, shortestDist +end + +function cfxOwnedZones.enemiesRemaining(aZone) + if cfxOwnedZones.getNearestEnemyOwnedZone(aZone) then return true end + return false +end + +function cfxOwnedZones.spawnAttackTroops(theTypes, aZone, aCoalition, aFormation) + local unitTypes = {} -- build type names + -- split theTypes into an array of types + unitTypes = dcsCommon.splitString(theTypes, ",") + if #unitTypes < 1 then + table.insert(unitTypes, "Soldier M4") -- make it one m4 trooper as fallback + -- simply exit, no troops specified + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: no attackers for " .. aZone.name .. ". exiting", 30) + end + return + end + + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: spawning attackers for " .. aZone.name, 30) + end + + --local theCountry = dcsCommon.coalition2county(aCoalition) + + local spawnPoint = {x = aZone.point.x, y = aZone.point.y, z = aZone.point.z} -- copy struct + + local rads = aZone.attackPhi * 0.01745 + spawnPoint.x = spawnPoint.x + math.cos(aZone.attackPhi) * aZone.attackDelta + spawnPoint.y = spawnPoint.y + math.sin(aZone.attackPhi) * aZone.attackDelta + + local spawnZone = cfxZones.createSimpleZone("attkSpawnZone", spawnPoint, aZone.attackRadius) + + local theGroup, theData = cfxZones.createGroundUnitsInZoneForCoalition ( + aCoalition, -- theCountry, + aZone.name .. " (A) " .. dcsCommon.numberUUID(), -- must be unique + spawnZone, + unitTypes, + aFormation, -- outward facing + 0) + return theGroup, theData +end + +function cfxOwnedZones.spawnDefensiveTroops(theTypes, aZone, aCoalition, aFormation) + local unitTypes = {} -- build type names + -- split theTypes into an array of types + unitTypes = dcsCommon.splitString(theTypes, ",") + if #unitTypes < 1 then + table.insert(unitTypes, "Soldier M4") -- make it one m4 trooper as fallback + -- simply exit, no troops specified + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: no defenders for " .. aZone.name .. ". exiting", 30) + end + return + end + + --local theCountry = dcsCommon.coalition2county(aCoalition) + local spawnZone = cfxZones.createSimpleZone("spawnZone", aZone.point, aZone.spawnRadius) + local theGroup, theData = cfxZones.createGroundUnitsInZoneForCoalition ( + aCoalition, --theCountry, + aZone.name .. " (D) " .. dcsCommon.numberUUID(), -- must be unique + spawnZone, unitTypes, + aFormation, -- outward facing + 0) + return theGroup, theData +end +-- +-- U P D A T E +-- + +function cfxOwnedZones.sendOutAttackers(aZone) + -- only spawn if there are zones to attack + if not cfxOwnedZones.enemiesRemaining(aZone) then + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ - no enemies, resting ".. aZone.name, 30) + end + return + end + + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ - attack cycle for ".. aZone.name, 30) + end + -- load the attacker typestring + + -- step one: get the attackers + local attackers = aZone.attackersRED; + if (aZone.owner == 2) then attackers = aZone.attackersBLUE end + + if attackers == "none" then return end + + local theGroup, theData = cfxOwnedZones.spawnAttackTroops(attackers, aZone, aZone.owner, aZone.attackFormation) + + local troopData = {} + troopData.groupData = theData + troopData.orders = "attackOwnedZone" -- lazy coding! + troopData.side = aZone.owner + cfxOwnedZones.spawnedAttackers[theData.name] = troopData + + -- submit them to ground troops handler as zoneseekers + -- and our groundTroops module will handle the rest + if cfxGroundTroops then + local troops = cfxGroundTroops.createGroundTroops(theGroup) + troops.orders = "attackOwnedZone" + troops.side = aZone.owner + cfxGroundTroops.addGroundTroopsToPool(troops) -- hand off to ground troops + else + if cfxOwnedZones.verbose then + trigger.action.outText("+++ Owned Zones: no ground troops module on send out attackers", 30) + end + end +end + +-- bang support + +function cfxOwnedZones.bangNeutral(value) + if not cfxOwnedZones.neutralTriggerFlag then return end + local newVal = trigger.misc.getUserFlag(cfxOwnedZones.neutralTriggerFlag) + value + trigger.action.setUserFlag(cfxOwnedZones.neutralTriggerFlag, newVal) +end + +function cfxOwnedZones.bangRed(value) + if not cfxOwnedZones.redTriggerFlag then return end + local newVal = trigger.misc.getUserFlag(cfxOwnedZones.redTriggerFlag) + value + trigger.action.setUserFlag(cfxOwnedZones.redTriggerFlag, newVal) +end + +function cfxOwnedZones.bangBlue(value) + if not cfxOwnedZones.blueTriggerFlag then return end + local newVal = trigger.misc.getUserFlag(cfxOwnedZones.blueTriggerFlag) + value + trigger.action.setUserFlag(cfxOwnedZones.blueTriggerFlag, newVal) +end + +function cfxOwnedZones.bangSide(theSide, value) + if theSide == 2 then + cfxOwnedZones.bangBlue(value) + return + end + if theSide == 1 then + cfxOwnedZones.bangRed(value) + return + end + cfxOwnedZones.bangNeutral(value) +end + +function cfxOwnedZones.zoneConquered(aZone, theSide, formerOwner) -- 0 = neutral 1 = RED 2 = BLUE + local who = "REDFORCE" + if theSide == 2 then who = "BLUEFORCE" + elseif theSide == 0 then who = "NEUTRAL" end + + if cfxOwnedZones.announcer then + if theSide == 0 then + trigger.action.outText(aZone.name .. " has become NEUTRAL", 30) + else + trigger.action.outText(who .. " have secured zone " .. aZone.name, 30) + end + aZone.owner = theSide -- just to be sure + -- play different sounds depending on who's won + if theSide == 1 then + trigger.action.outSoundForCoalition(1, cfxOwnedZones.winSound) + trigger.action.outSoundForCoalition(2, cfxOwnedZones.loseSound) + elseif theSide == 2 then + trigger.action.outSoundForCoalition(2, cfxOwnedZones.winSound) + trigger.action.outSoundForCoalition(1, cfxOwnedZones.loseSound) + else + -- no sound played, new owner is neutral + end + end + + if aZone.conqueredFlag then + cfxZones.pollFlag(aZone.conqueredFlag, "inc", aZone) + end + + if theSide == 1 and aZone.redCap then + cfxZones.pollFlag(aZone.redCap, "inc", aZone) + end + + if formerOwner == 1 and aZone.redLost then + cfxZones.pollFlag(aZone.redLost, "inc", aZone) + end + + if theSide == 2 and aZone.blueCap then + cfxZones.pollFlag(aZone.blueCap, "inc", aZone) + end + + if formerOwner == 2 and aZone.blueLost then + cfxZones.pollFlag(aZone.blueLost, "inc", aZone) + end + + if theSide == 0 and aZone.neutralCap then + cfxZones.pollFlag(aZone.neutralCap, "inc", aZone) + end + + -- invoke callbacks now + cfxOwnedZones.invokeConqueredCallbacks(aZone, theSide, formerOwner) + + -- bang! flag support + cfxOwnedZones.bangSide(theSide, 1) -- winner + cfxOwnedZones.bangSide(formerOwner, -1) -- loser + + -- update map + cfxOwnedZones.drawZoneInMap(aZone) -- update status in map. will erase previous version + -- remove all defenders to avoid shock state + aZone.defenders = nil + + -- change to captured + + aZone.state = "captured" + aZone.timeStamp = timer.getTime() +end + +function cfxOwnedZones.repairDefenders(aZone) + --trigger.action.outText("+++ enter repair for ".. aZone.name, 30) + -- find a unit that is missing from my typestring and replace it + -- one by one until we are back to full strength + -- step one: get the defenders and create a type array + local defenders = aZone.defendersRED; + if (aZone.owner == 2) then defenders = aZone.defendersBLUE end + local unitTypes = {} -- build type names + + -- if none, we are done + if defenders == "none" then return end + + -- split theTypes into an array of types + allTypes = dcsCommon.trimArray( + dcsCommon.splitString(defenders, ",") + ) + local livingTypes = {} -- init to emtpy, so we can add to it if none are alive + if (aZone.defenders) then + -- some remain. add one of the killed + livingTypes = dcsCommon.getGroupTypes(aZone.defenders) + -- we now iterate over the living types, and remove their + -- counterparts from the allTypes. We then take the first that + -- is left + + if #livingTypes > 0 then + for key, aType in pairs (livingTypes) do + if not dcsCommon.findAndRemoveFromTable(allTypes, aType) then + trigger.action.outText("+++OwdZ WARNING: found unmatched type <" .. aType .. "> while trying to repair defenders for ".. aZone.name, 30) + else + -- all good + end + end + end + end + + -- when we get here, allTypes is reduced to those that have been killed + if #allTypes < 1 then + trigger.action.outText("+++owdZ: WARNING: all types exist when repairing defenders for ".. aZone.name, 30) + else + table.insert(livingTypes, allTypes[1]) -- we simply use the first that we find + end + -- remove the old defenders + if aZone.defenders then + aZone.defenders:destroy() + end + + -- now livingTypes holds the full array of units we need to spawn + local theCountry = dcsCommon.getACountryForCoalition(aZone.owner) + local spawnZone = cfxZones.createSimpleZone("spawnZone", aZone.point, aZone.spawnRadius) + local theGroup, theData = cfxZones.createGroundUnitsInZoneForCoalition ( + aZone.owner, -- was wrongly: theCountry + aZone.name .. dcsCommon.numberUUID(), -- must be unique + spawnZone, + livingTypes, + + aZone.formation, -- outward facing + 0) + aZone.defenders = theGroup + aZone.lastDefenders = theGroup:getSize() +end + +function cfxOwnedZones.inShock(aZone) + -- a unit was destroyed, everyone else is in shock, no rerpairs + -- group can re-shock when another unit is destroyed +end + +function cfxOwnedZones.spawnDefenders(aZone) + local defenders = aZone.defendersRED; + + if (aZone.owner == 2) then defenders = aZone.defendersBLUE end + -- before we spawn new defenders, remove the old ones + if aZone.defenders then + if aZone.defenders:isExist() then + aZone.defenders:destroy() + end + aZone.defenders = nil + end + + -- if 'none', simply exit + if defenders == "none" then return end + + local theGroup, theData = cfxOwnedZones.spawnDefensiveTroops(defenders, aZone, aZone.owner, aZone.formation) + -- the troops reamin, so no orders to move, no handing off to ground troop manager + aZone.defenders = theGroup + aZone.defenderData = theData -- used for persistence + if theGroup then + --aZone.defenderMax = theGroup:getInitialSize() -- so we can determine if some units were destroyed + aZone.lastDefenders = theGroup:getInitialSize() --- aZone.defenderMax -- if this is larger than current number, someone bit the dust + --trigger.action.outText("+++ spawned defenders for ".. aZone.name, 30) + else + trigger.action.outText("+++owdZ: WARNING: spawned no defenders for ".. aZone.name, 30) + aZone.defenderData = nil + end +end + +-- +-- per-zone update, run down the FSM to determine what to do. +-- FSM uses timeStamp since when state was set. Possible states are +-- - init -- has just been inited for the first time. will usually immediately produce defenders, +-- and then transition to defending +-- - catured -- has just been captured. transition to defending +-- - defending -- wait until timer has reached goal, then produce defending units and transition to attacking. +-- - attacking -- wait until timer has reached goal, and then produce attacking units and send them to closest enemy zone. +-- state is interrupted as soon as a defensive unit is lost. state then goes to defending with timer starting +-- - idle - do nothing, zone's actions are turned off +-- - shocked -- a unit was destroyed. group is in shock for a time until it starts repairing. If another unit is +-- destroyed during the shocked period, the timer resets to zero and repairs are delayed +-- - repairing -- as long as we aren't at full strength, units get replaced one by one until at full strength +-- each time the timer counts down, another missing unit is replaced, and all other unit's health +-- is reset to 100% +-- +-- a Zone with the paused attribute set to true will cause it to not do anything +-- +-- check if defenders are specified +function cfxOwnedZones.usesDefenders(aZone) + if aZone.owner == 0 then return false end + local defenders = aZone.defendersRED; + if (aZone.owner == 2) then defenders = aZone.defendersBLUE end + + return defenders ~= "none" +end + +function cfxOwnedZones.usesAttackers(aZone) + if aZone.owner == 0 then return false end + local attackers = aZone.attackersRED; + if (aZone.owner == 2) then defenders = aZone.attackersBLUE end + + return attackers ~= "none" +end + +function cfxOwnedZones.updateZone(aZone) + -- a zone can be paused, causing it to not progress anything + -- even if zone status is still init, will NOT produce anything + -- if paused is on. + if aZone.paused then return end + + nextState = aZone.state; + + -- first, check if my defenders have been attacked and one of them has been killed + -- if so, we immediately switch to 'shocked' + if cfxOwnedZones.usesDefenders(aZone) and + aZone.defenders then + -- we have defenders + if aZone.defenders:isExist() then + -- isee if group was damaged + if not aZone.lastDefenders then + -- fresh group, probably from persistence, needs init + aZone.lastDefenders = -1 + end + if aZone.defenders:getSize() < aZone.lastDefenders then + -- yes, at least one unit destroyed + aZone.timeStamp = timer.getTime() + aZone.lastDefenders = aZone.defenders:getSize() + if aZone.lastDefenders == 0 then + aZone.defenders = nil + end + aZone.state = "shocked" + + return + else + aZone.lastDefenders = aZone.defenders:getSize() + end + + else + -- group was destroyed. erase link, and go into shock for the last time + aZone.state = "shocked" + aZone.timeStamp = timer.getTime() + aZone.lastDefenders = 0 + aZone.defenders = nil + + return + end + end + + + if aZone.state == "init" then + -- during init we instantly create the defenders since + -- we assume the zone existed already + if aZone.owner > 0 then + cfxOwnedZones.spawnDefenders(aZone) + -- now drop into attacking mode to produce attackers + nextState = "attacking" + else + nextState = "idle" + end + + aZone.timeStamp = timer.getTime() + + elseif aZone.state == "idle" then + -- nothing to do, zone is effectively switched off. + -- used for neutal zones or when forced to turn off + -- in some special cases + + elseif aZone.state == "captured" then + -- start the clock on defenders + nextState = "defending" + aZone.timeStamp = timer.getTime() + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) + end + elseif aZone.state == "defending" then + if timer.getTime() > aZone.timeStamp + cfxOwnedZones.defendingTime then + cfxOwnedZones.spawnDefenders(aZone) + -- now drop into attacking mode to produce attackers + nextState = "attacking" + aZone.timeStamp = timer.getTime() + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) + end + end + + elseif aZone.state == "repairing" then + -- we are currently rebuilding defenders unit by unit + if timer.getTime() > aZone.timeStamp + cfxOwnedZones.repairTime then + aZone.timeStamp = timer.getTime() + -- wait's up, repair one defender, then check if full strength + cfxOwnedZones.repairDefenders(aZone) + -- see if we are full strenght and if so go to attack, else set timer to reair the next unit + if aZone.defenders and aZone.defenders:isExist() and aZone.defenders:getSize() >= aZone.defenders:getInitialSize() then + -- we are at max size, time to produce some attackers + -- progress to next state + nextState = "attacking" + aZone.timeStamp = timer.getTime() + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) + end + end + + end + + elseif aZone.state == "shocked" then + -- we are currently rebuilding defenders unit by unit + if timer.getTime() > aZone.timeStamp + cfxOwnedZones.shockTime then + nextState = "repairing" + aZone.timeStamp = timer.getTime() + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) + end + end + + elseif aZone.state == "attacking" then + if timer.getTime() > aZone.timeStamp + cfxOwnedZones.attackingTime then + cfxOwnedZones.sendOutAttackers(aZone) + -- reset timer + aZone.timeStamp = timer.getTime() + if cfxOwnedZones.verbose then + trigger.action.outText("+++owdZ: State " .. aZone.state .. " reset for " .. aZone.name, 30) + end + end + else + -- unknown zone state + end + aZone.state = nextState +end + +function cfxOwnedZones.GC() + -- GC run. remove all my dead remembered troops + local before = #cfxOwnedZones.spawnedAttackers + local filteredAttackers = {} + for gName, gData in pairs (cfxOwnedZones.spawnedAttackers) do + -- all we need to do is get the group of that name + -- and if it still returns units we are fine + local gameGroup = Group.getByName(gName) + if gameGroup and gameGroup:isExist() and gameGroup:getSize() > 0 then + filteredAttackers[gName] = gData + end + end + cfxOwnedZones.spawnedAttackers = filteredAttackers + if cfxOwnedZones.verbose then + trigger.action.outText("owned zones GC ran: before <" .. before .. ">, after <" .. #cfxOwnedZones.spawnedAttackers .. ">", 30) + end +end + +function cfxOwnedZones.update() + -- to speed this up we might only want to check the first unit + -- in group, and if inside, count the entire group as inside + -- new. unit counting update + cfxOwnedZones.updateSchedule = timer.scheduleFunction(cfxOwnedZones.update, {}, timer.getTime() + 1/cfxOwnedZones.ups) + -- iterate all groups and their units to count how many + -- units are in each zone + for idz, theZone in pairs(cfxOwnedZones.zones) do + theZone.numRed = 0 + theZone.numBlue = 0 + -- count red units + local allRed = coalition.getGroups(1, Group.Category.GROUND) + for idx, aGroup in pairs(allRed) do + if Group.isExist(aGroup) then + if cfxOwnedZones.fastEval then + -- we only check first unit that is alive + local theUnit = dcsCommon.getGroupUnit(aGroup) + if theUnit and cfxZones.unitInZone(theUnit, theZone) then + theZone.numRed = theZone.numRed + aGroup:getSize() + end + else + local allUnits = aGroup:getUnits() + for idy, theUnit in pairs(allUnits) do + if cfxZones.unitInZone(theUnit, theZone) then + theZone.numRed = theZone.numRed + 1 + end + end + end + end + end + -- count blue units + local allBlue = coalition.getGroups(2, Group.Category.GROUND) + for idx, aGroup in pairs(allBlue) do + if Group.isExist(aGroup) then + if cfxOwnedZones.fastEval then + -- we only check first unit that is alive + local theUnit = dcsCommon.getGroupUnit(aGroup) + if theUnit and cfxZones.unitInZone(theUnit, theZone) then + theZone.numBlue = theZone.numBlue + aGroup:getSize() + end + else + local allUnits = aGroup:getUnits() + for idy, theUnit in pairs(allUnits) do + if cfxZones.unitInZone(theUnit, theZone) then + theZone.numBlue = theZone.numBlue + 1 + end + end + end + end + end + -- trigger.action.outText(theZone.name .. " blue: " .. theZone.numBlue .. " red " .. theZone.numRed, 30) + local lastOwner = theZone.owner + local newOwner = 0 -- neutral is default + -- determine new owner + -- step one: no troops here. Become neutral? + if theZone.numRed < 1 and theZone.numBlue < 1 then + if cfxOwnedZones.numKeep < 1 then + newOwner = lastOwner -- keep it, else turns neutral + else + -- noone here, zone becomes neutral + newOwner = 0 -- not strictly required. to be explicit + end + elseif theZone.numRed < 1 then + -- only blue here. enough to keep? + if theZone.numBlue >= cfxOwnedZones.numCap then + newOwner = 2 -- blue owns it + elseif lastOwner == 2 and theZone.numBlue >= cfxOwnedZones.numKeep then + -- enough to keep if owned before + newOwner = 2 + else + newOwner = 0 -- just to make it explicit + end + elseif theZone.numBlue < 1 then + -- only red here. enough to keep? + if theZone.numRed >= cfxOwnedZones.numCap then + newOwner = 1 + elseif lastOwner == 1 and theZone.numRed >= cfxOwnedZones.numKeep then + newOwner = 1 + else + newOwner = 0 + end + else + -- blue and red units here. + -- owner keeps hanging on only they have enough + -- units left + if cfxOwnedZones.easyContest then + -- this zone is immediately contested + newOwner = 0 -- just to be explicit + elseif cfxOwnedZones.numKeep < 1 then + -- old owner keeps it until none left + newOwner = lastOwner + else + if lastOwner == 1 then + -- red can keep it as long as enough units here + if theZone.numRed >= cfxOwnedZones.numKeep then + newOwner = 1 + end -- else 0 + elseif lastOwner == 2 then + -- blue can keep it if enough units here + if theZone.numBlue >= cfxOwnedZones.numKeep then + newOwner = 2 + end -- else 0 + else -- stay 0 + end + end + end + + -- now see if owner changed, and react accordingly + if newOwner == lastOwner then + -- nothing happened, do nothing + else + trigger.action.outText(theZone.name .. " change hands from " .. lastOwner .. " to " .. newOwner, 30) + if newOwner == 0 then -- zone turned neutral + cfxOwnedZones.zoneConquered(theZone, newOwner, lastOwner) + else + cfxOwnedZones.zoneConquered(theZone, newOwner, lastOwner) + end + end + theZone.owner = newOwner + + -- production & flags + -- see if pause/unpause was issued + -- note that capping a zone will not change pause status + if theZone.pauseFlag and cfxZones.testZoneFlag(theZone, theZone.pauseFlag, theZone.ownedTriggerMethod, "lastPauseValue") then + theZone.paused = true + end + + if theZone.activateFlag and cfxZones.testZoneFlag(theZone, theZone.activateFlag, theZone.ownedTriggerMethod, "lastActivateValue") then + theZone.paused = false + end + + -- update ownership flag if exists + if theZone.ownedBy then + cfxZones.setFlagValue(theZone.ownedBy, theZone.owner, theZone) + end + + -- now, perhaps with their new owner call updateZone() + -- to calcualte production for this zone + cfxOwnedZones.updateZone(theZone) + end -- iterating all zones +end + + +function cfxOwnedZones.updateOLD() + cfxOwnedZones.updateSchedule = timer.scheduleFunction(cfxOwnedZones.updateOLD, {}, timer.getTime() + 1/cfxOwnedZones.ups) + + -- iterate all zones, and determine their current ownership status + for key, aZone in pairs(cfxOwnedZones.zones) do + -- a hand change can only happen if there are only ground troops from the OTHER side in + -- the zone + local categ = Group.Category.GROUND + local theBlues = cfxZones.groupsOfCoalitionPartiallyInZone(2, aZone, categ) + local theReds = cfxZones.groupsOfCoalitionPartiallyInZone(1, aZone, categ) + local currentOwner = aZone.owner + if #theBlues > 0 and #theReds == 0 and aZone.unbeatable ~= true then + -- this now belongs to blue + if currentOwner ~= 2 then + cfxOwnedZones.zoneConquered(aZone, 2, currentOwner) + end + elseif #theBlues == 0 and #theReds > 0 and aZone.unbeatable ~= true then + -- this now belongs to red + if currentOwner ~= 1 then + cfxOwnedZones.zoneConquered(aZone, 1, currentOwner) + end + end + + -- see if pause/unpause was issued + -- note that capping a zone will not change pause status + if aZone.pauseFlag and cfxZones.testZoneFlag(aZone, aZone.pauseFlag, aZone.ownedTriggerMethod, "lastPauseValue") then + aZone.paused = true + end + + if aZone.activateFlag and cfxZones.testZoneFlag(aZone, aZone.activateFlag, aZone.ownedTriggerMethod, "lastActivateValue") then + aZone.paused = false + end + + -- now, perhaps with their new owner call updateZone() + cfxOwnedZones.updateZone(aZone) + end + +end + +function cfxOwnedZones.houseKeeping() + timer.scheduleFunction(cfxOwnedZones.houseKeeping, {}, timer.getTime() + 5 * 60) -- every 5 minutes + cfxOwnedZones.GC() +end + +function cfxOwnedZones.sideOwnsAll(theSide) + for key, aZone in pairs(cfxOwnedZones.zones) do + if aZone.owner ~= theSide then + return false + end + end + -- if we get here, all your base are belong to us + return true +end + +function cfxOwnedZones.hasOwnedZones() + for idx, zone in pairs (cfxOwnedZones.zones) do + return true -- even the first returns true + end + -- no owned zones + return false +end + + +-- +-- load / save data +-- + +function cfxOwnedZones.saveData() + -- this is called from persistence when it's time to + -- save data. returns a table with all my data + local theData = {} + local allZoneData = {} + -- iterate all my zones and create data + for idx, theZone in pairs(cfxOwnedZones.zones) do + local zoneData = {} + if theZone.defenderData then + zoneData.defenderData = dcsCommon.clone(theZone.defenderData) + dcsCommon.synchGroupData(zoneData.defenderData) + end + if theZone.conqueredFlag then + zoneData.conquered = cfxZones.getFlagValue(theZone.conqueredFlag, theZone) + end + zoneData.owner = theZone.owner + zoneData.state = theZone.state -- will prevent immediate spawn + -- since new zones are spawned with 'init' + allZoneData[theZone.name] = zoneData + end + + -- now iterate all attack groups that we have spawned and that + -- (maybe) are still alive + cfxOwnedZones.GC() -- start with a GC run to remove all dead + local livingAttackers = {} + for gName, gData in pairs (cfxOwnedZones.spawnedAttackers) do + -- all we need to do is get the group of that name + -- and if it still returns units we are fine + -- spawnedAttackers is a [groupName] table with {.groupData, .orders, .side} + local gameGroup = Group.getByName(gName) + if gameGroup and gameGroup:isExist() then + if gameGroup:getSize() > 0 then + local sData = dcsCommon.clone(gData) + dcsCommon.synchGroupData(sData.groupData) + livingAttackers[gName] = sData + end + end + end + + -- now write the info for the flags that we output for #red, etc + local flagInfo = {} + flagInfo.neutral = cfxZones.getFlagValue(cfxOwnedZones.neutralTriggerFlag, cfxOwnedZones) + flagInfo.red = cfxZones.getFlagValue(cfxOwnedZones.redTriggerFlag, cfxOwnedZones) + flagInfo.blue = cfxZones.getFlagValue(cfxOwnedZones.blueTriggerFlag, cfxOwnedZones) + -- assemble the data + theData.zoneData = allZoneData + theData.attackers = livingAttackers + theData.flagInfo = flagInfo + + -- return it + return theData +end + +function cfxOwnedZones.loadData() + -- remember to draw in map with new owner + if not persistence then return end + local theData = persistence.getSavedDataForModule("cfxOwnedZones") + if not theData then + if cfxOwnedZones.verbose then + trigger.action.outText("owdZ: no save date received, skipping.", 30) + end + return + end + -- theData contains the following tables: + -- zoneData: per-zone data + -- flagInfo: module-global flags + -- attackers: all spawned attackers that we feed to groundTroops + local allZoneData = theData.zoneData + for zName, zData in pairs(allZoneData) do + -- access zone + local theZone = cfxOwnedZones.getOwnedZoneByName(zName) + if theZone then + if zData.defenderData then + if theZone.defenders and theZone.defenders:isExist() then + -- should not happen, but so be it + theZone.defenders:destroy() + end + local gData = zData.defenderData + local cty = gData.cty + local cat = gData.cat + theZone.defenders = coalition.addGroup(cty, cat, gData) + theZone.defenderData = zData.defenderData + end + theZone.owner = zData.owner + theZone.state = zData.state + if zData.conquered then + cfxZones.setFlagValue(theZone.conqueredFlag, zData.conquered, theZone) + end + -- update mark in map + cfxOwnedZones.drawZoneInMap(theZone) + else + trigger.action.outText("owdZ: load - data mismatch: cannot find zone <" .. zName .. ">, skipping zone.", 30) + end + end + + -- now process all attackers + local allAttackers = theData.attackers + for gName, gdTroop in pairs(allAttackers) do + -- table is {.groupData, .orders, .side} + local gData = gdTroop.groupData + local orders = gdTroop.orders + local side = gdTroop.side + local cty = gData.cty + local cat = gData.cat + -- add to my own attacker queue so we can save later + local dClone = dcsCommon.clone(gdTroop) + cfxOwnedZones.spawnedAttackers[gName] = dClone + local theGroup = coalition.addGroup(cty, cat, gData) + if cfxGroundTroops then + local troops = cfxGroundTroops.createGroundTroops(theGroup) + troops.orders = orders + troops.side = side + cfxGroundTroops.addGroundTroopsToPool(troops) -- hand off to ground troops + end + end + + -- now process module global flags + local flagInfo = theData.flagInfo + if flagInfo then + cfxZones.setFlagValue(cfxOwnedZones.neutralTriggerFlag, flagInfo.neutral, cfxOwnedZones) + cfxZones.setFlagValue(cfxOwnedZones.redTriggerFlag, flagInfo.red, cfxOwnedZones) + cfxZones.setFlagValue(cfxOwnedZones.blueTriggerFlag, flagInfo.blue, cfxOwnedZones) + end +end + + +-- +function cfxOwnedZones.readConfigZone(theZone) + if not theZone then theZone = cfxZones.createSimpleZone("ownedZonesConfig") end + + cfxOwnedZones.name = "cfxOwnedZones" -- just in case, so we can access with cfxZones + cfxOwnedZones.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) + cfxOwnedZones.announcer = cfxZones.getBoolFromZoneProperty(theZone, "announcer", true) + +-- if cfxZones.hasProperty(theZone, "r!") then + cfxOwnedZones.redTriggerFlag = cfxZones.getStringFromZoneProperty(theZone, "r!", "*") +-- end +-- if cfxZones.hasProperty(theZone, "b!") then + cfxOwnedZones.blueTriggerFlag = cfxZones.getStringFromZoneProperty(theZone, "b!", "*") +-- end +-- if cfxZones.hasProperty(theZone, "n!") then + cfxOwnedZones.neutralTriggerFlag = cfxZones.getStringFromZoneProperty(theZone, "n!", "*") +-- end + cfxOwnedZones.defendingTime = cfxZones.getNumberFromZoneProperty(theZone, "defendingTime", 100) + cfxOwnedZones.attackingTime = cfxZones.getNumberFromZoneProperty(theZone, "attackingTime", 300) + cfxOwnedZones.shockTime = cfxZones.getNumberFromZoneProperty(theZone, "shockTime", 200) + cfxOwnedZones.repairTime = cfxZones.getNumberFromZoneProperty(theZone, "repairTime", 200) + -- numKeep, numCap, fastEval, easyContest + cfxOwnedZones.numCap = cfxZones.getNumberFromZoneProperty(theZone, "numCap", 1) -- minimal number of units required to cap zone + cfxOwnedZones.numKeep = cfxZones.getNumberFromZoneProperty(theZone, "numKeep", 0) -- number required to keep zone + cfxOwnedZones.fastEval = cfxZones.getBoolFromZoneProperty(theZone, "fastEval", true) + cfxOwnedZones.easyContest = cfxZones.getBoolFromZoneProperty(theZone, "easyContest", false) + -- winSound, loseSound + cfxOwnedZones.winSound = cfxZones.getStringFromZoneProperty(theZone, "winSound", "Quest Snare 3.wav" ) + cfxOwnedZones.loseSound = cfxZones.getStringFromZoneProperty(theZone, "loseSound", "Death BRASS.wav") +end + +function cfxOwnedZones.init() + -- check libs + if not dcsCommon.libCheck("cfx Owned Zones", + cfxOwnedZones.requiredLibs) then + return false + end + + -- read my config zone + local theZone = cfxZones.getZoneByName("ownedZonesConfig") + cfxOwnedZones.readConfigZone(theZone) + + + -- collect all owned zones by their 'owner' property + -- start the process + local pZones = cfxZones.zonesWithProperty("owner") + + -- now add all zones to my zones table, and convert the owner property into + -- a proper attribute + for k, aZone in pairs(pZones) do + cfxOwnedZones.addOwnedZone(aZone) + end + + if persistence then + -- sign up for persistence + callbacks = {} + callbacks.persistData = cfxOwnedZones.saveData + persistence.registerModule("cfxOwnedZones", callbacks) + -- now load my data + cfxOwnedZones.loadData() + end + + initialized = true + cfxOwnedZones.updateSchedule = timer.scheduleFunction(cfxOwnedZones.update, {}, timer.getTime() + 1/cfxOwnedZones.ups) + + -- start housekeeping + cfxOwnedZones.houseKeeping() + + trigger.action.outText("cx/x owned zones v".. cfxOwnedZones.version .. " started", 30) + + return true +end + +if not cfxOwnedZones.init() then + trigger.action.outText("cf/x Owned Zones aborted: missing libraries", 30) + cfxOwnedZones = nil +end + + + diff --git a/modules/cfxOwnedZones.lua b/modules/cfxOwnedZones.lua index 792bdd8..ca80266 100644 --- a/modules/cfxOwnedZones.lua +++ b/modules/cfxOwnedZones.lua @@ -1,66 +1,11 @@ cfxOwnedZones = {} -cfxOwnedZones.version = "1.3.0" +cfxOwnedZones.version = "2.0.0" cfxOwnedZones.verbose = false cfxOwnedZones.announcer = true cfxOwnedZones.name = "cfxOwnedZones" --[[-- VERSION HISTORY -1.0.3 - added getNearestFriendlyZone - - added getNearestOwnedZone - - added hasOwnedZones - - added getNearestOwnedZoneToPoint -1.0.4 - changed addOwnedZone code to use cfxZone.getCoalitionFromZoneProperty - - changed to use dcsCommon.coalition2county - - changed to using correct coalition for spawing attackers and defenders -1.0.5 - repairing defenders switches to country instead coalition when calling createGroundUnitsInZoneForCoalition -- fixed -1.0.6 - removed call to conqTemplate - - verified that pause will also apply to init - - unbeatable zones - - untargetable zones - - hidden attribute -1.0.7 - optional cfxGroundTroops module, error message when attackers - - support of 'none' type string to indicate no attackers/defenders - - updated property access - - module check - - cfxOwnedZones.usesDefenders(aZone) - - verifyZone -1.0.8 - repairDefenders trims types to allow blanks in - type separator -1.1.0 - config zone - - bang! support r, b, n capture - - defaulting attackDelta to 10 instead of radius - - verbose code for spawning - - verbose code for state transition - - attackers have (A) in name, defenders (D) - - exit createDefenders if no troops - - exit createAttackers if no troops - - usesAttackers/usesDefenders checks for neutral ownership - - verbose state change - - nearestZone supports moving zones - - remove exiting defenders from zone after cap to avoid - shocked state - - announcer -1.1.1 - conq+1 flag -1.1.2 - corrected type bug in zoneConquered -1.2.0 - support for persistence - - conq+1 --> conquered! - - no cfxGroundTroop bug (no delay) -1.2.1 - fix in load to correctly re-establish all attackers for subsequent save -1.2.2 - redCap! and blueCap! -1.2.3 - fix for persistence bug when not using conquered flag -1.2.4 - pause? and activate? inputs -1.3.0 - new update method - - new fastEval option in config - - new numCap option in config - - new numKeep option in config - - new easyContest option in config - - new logic to keep and lose zones. controlled with numKeep and numCap. - - winSound - - loseSound - - redLost! zone output - - blueLost! zone output - - ownedBy direct zone output - - neutral! zone output +2.0.0 - factored from cfxOwnedZones 1.x, separating out production --]]-- cfxOwnedZones.requiredLibs = { @@ -68,60 +13,27 @@ cfxOwnedZones.requiredLibs = { -- pretty stupid to check for this since we -- need common to invoke the check, but anyway "cfxZones", -- Zones, of course - "cfxCommander", -- to make troops do stuff --- "cfxGroundTroops", -- optional, used for attackers only } cfxOwnedZones.zones = {} cfxOwnedZones.ups = 1 cfxOwnedZones.initialized = false -cfxOwnedZones.defendingTime = 100 -- 100 seconds until new defenders are produced -cfxOwnedZones.attackingTime = 300 -- 300 seconds until new attackers are produced -cfxOwnedZones.shockTime = 200 -- 200 -- 'shocked' period of inactivity -cfxOwnedZones.repairTime = 200 -- 200 -- time until we raplace one lost unit, also repairs all other units to 100% +--[[-- + owned zones is a module that managers conquerable zones and keeps a record + of who owns the zone based on rules --- persistence: all attackers we ever sent out. --- is regularly verified and cut to size -cfxOwnedZones.spawnedAttackers = {} + + *** EXTENTDS ZONES ***, so compatible with cfxZones, pilotSafe (limited airframes), may conflict with FARPZones --- owned zones is a module that managers 'conquerable' zones and keeps a --- record of who owns the zone --- based on some simple rules that are regularly checked --- --- *** EXTENTDS ZONES ***, so compatible with cfxZones, pilotSafe (limited airframes), may conflict with FARPZones --- + owned zones are identified by the 'owner' property. It can be initially set to nothing (default), NEUTRAL, RED or BLUE --- owned zones are identified by the 'owner' property. It can be initially set to nothing (default), NEUTRAL, RED or BLUE - --- when a zone changes hands, a callback can be installed to be told of that fact --- callback has the format (zone, newOwner, formerOwner) with zone being the Zone, and new owner and former owners + when a zone changes hands, a callback can be installed to be told of that fact + callback has the format (zone, newOwner, formerOwner) with zone being the Zone, and new owner and former owners + --]]-- + cfxOwnedZones.conqueredCallbacks = {} --- --- zone attributes when owned --- owner: coalition that owns the zone --- status: FSM for spawning --- defendersRED/BLUE - coma separated type string for the group to spawn on defense cycle completion --- attackersRED/BLUE - as above for attack cycle. --- timeStamp - time when zone switched into current state --- spawnRadius - overrides zone's radius when placing defenders. can be use to place defenders inside or outside zone itself --- formation - defender's formation --- attackFormation - attackers formation --- attackRadius - radius of circle in which attackers are spawned. informs formation --- attackDelta - polar coord: r from zone center where attackers are spawned --- attackPhi - polar degrees where attackers are to be spawned --- paused - will not spawn. default is false --- unbeatable - can't be conquered by other side. default is false --- untargetable - will not be targeted by either side. make unbeatable --- owned zones untargetable, or they'll become a troop magnet for --- zoneAttackers --- hidden - if set (default no), it no markings on the map --- --- to create an owned zone that can't be conquered and does nothing --- add the following properties to a zone --- owner = , paused = true, unbeatable = true - -- -- callback handling -- @@ -173,7 +85,7 @@ function cfxOwnedZones.drawZoneInMap(aZone) local lineColor = {1.0, 0, 0, 1.0} -- red local fillColor = {1.0, 0, 0, 0.2} -- red - local owner = cfxOwnedZones.getOwnerForZone(aZone) + local owner = aZone.owner -- cfxOwnedZones.getOwnerForZone(aZone) if owner == 2 then lineColor = {0.0, 0, 1.0, 1.0} fillColor = {0.0, 0, 1.0, 0.2} @@ -198,337 +110,46 @@ function cfxOwnedZones.getOwnedZoneByName(zName) end function cfxOwnedZones.addOwnedZone(aZone) - local owner = cfxZones.getCoalitionFromZoneProperty(aZone, "owner", 0) -- is already readm read it again - - aZone.owner = owner -- add this attribute to zone - - -- now init all other owned zone properties - aZone.state = "init" - aZone.timeStamp = timer.getTime() - --aZone.defendersRED = "Soldier M4,Soldier M4,Soldier M4,Soldier M4,Soldier M4" -- vehicles allocated to defend when red - - aZone.defendersRED = cfxZones.getStringFromZoneProperty(aZone, "defendersRED", "none") - aZone.defendersBLUE = cfxZones.getStringFromZoneProperty(aZone, "defendersBLUE", "none") - - aZone.attackersRED = cfxZones.getStringFromZoneProperty(aZone, "attackersRED", "none") - aZone.attackersBLUE = cfxZones.getStringFromZoneProperty(aZone, "attackersBLUE", "none") - - local formation = cfxZones.getZoneProperty(aZone, "formation") - if not formation then formation = "circle_out" end - aZone.formation = formation - formation = cfxZones.getZoneProperty(aZone, "attackFormation") - if not formation then formation = "circle_out" end - aZone.attackFormation = formation - local spawnRadius = cfxZones.getNumberFromZoneProperty(aZone, "spawnRadius", aZone.radius-5) -- "-5" so they remaininside radius - - aZone.spawnRadius = spawnRadius - - local attackRadius = cfxZones.getNumberFromZoneProperty(aZone, "attackRadius", aZone.radius) - aZone.attackRadius = attackRadius - local attackDelta = cfxZones.getNumberFromZoneProperty(aZone, "attackDelta", 10) -- aZone.radius) - aZone.attackDelta = attackDelta - local attackPhi = cfxZones.getNumberFromZoneProperty(aZone, "attackPhi", 0) - aZone.attackPhi = attackPhi - - local paused = cfxZones.getBoolFromZoneProperty(aZone, "paused", false) - aZone.paused = paused + local owner = aZone.owner --cfxZones.getCoalitionFromZoneProperty(aZone, "owner", 0) -- is already readm read it again if cfxZones.hasProperty(aZone, "conquered!") then aZone.conqueredFlag = cfxZones.getStringFromZoneProperty(aZone, "conquered!", "*") - elseif cfxZones.hasProperty(aZone, "conq+1") then - aZone.conqueredFlag = cfxZones.getStringFromZoneProperty(aZone, "conq+1", "*") end - if cfxZones.hasProperty(aZone, "redCap!") then aZone.redCap = cfxZones.getStringFromZoneProperty(aZone, "redCap!", "none") end - if cfxZones.hasProperty(aZone, "redLost!") then aZone.redLost = cfxZones.getStringFromZoneProperty(aZone, "redLost!", "none") end - if cfxZones.hasProperty(aZone, "blueCap!") then aZone.blueCap = cfxZones.getStringFromZoneProperty(aZone, "blueCap!", "none") end - if cfxZones.hasProperty(aZone, "blueLost!") then aZone.blueLost = cfxZones.getStringFromZoneProperty(aZone, "blueLost!", "none") end - if cfxZones.hasProperty(aZone, "neutral!") then aZone.neutralCap = cfxZones.getStringFromZoneProperty(aZone, "neutral!", "none") end - if cfxZones.hasProperty(aZone, "ownedBy") then aZone.ownedBy = cfxZones.getStringFromZoneProperty(aZone, "ownedBy", "none") end - - -- pause? and activate? - if cfxZones.hasProperty(aZone, "pause?") then - aZone.pauseFlag = cfxZones.getStringFromZoneProperty(aZone, "pause?", "none") - aZone.lastPauseValue = trigger.misc.getUserFlag(aZone.pauseFlag) - end - - if cfxZones.hasProperty(aZone, "activate?") then - aZone.activateFlag = cfxZones.getStringFromZoneProperty(aZone, "activate?", "none") - aZone.lastActivateValue = trigger.misc.getUserFlag(aZone.activateFlag) - end - + aZone.ownedTriggerMethod = cfxZones.getStringFromZoneProperty(aZone, "triggerMethod", "change") if cfxZones.hasProperty(aZone, "ownedTriggerMethod") then aZone.ownedTriggerMethod = cfxZones.getStringFromZoneProperty(aZone, "ownedTriggerMethod", "change") end - - + aZone.unbeatable = cfxZones.getBoolFromZoneProperty(aZone, "unbeatable", false) - aZone.untargetable = cfxZones.getBoolFromZoneProperty(aZone, "untargetable", false) + aZone.hidden = cfxZones.getBoolFromZoneProperty(aZone, "hidden", false) cfxOwnedZones.zones[aZone] = aZone cfxOwnedZones.drawZoneInMap(aZone) - cfxOwnedZones.verifyZone(aZone) end -function cfxOwnedZones.verifyZone(aZone) - -- do some sanity checks - if not cfxGroundTroops and (aZone.attackersRED ~= "none" or aZone.attackersBLUE ~= "none") then - trigger.action.outText("+++owdZ: " .. aZone.name .. " attackers need cfxGroundTroops to function", 30) - end - -end - -function cfxOwnedZones.getOwnerForZone(aZone) - local theZone = cfxOwnedZones.zones[aZone] - if not theZone then return 0 end -- unknown zone, return neutral as default - return theZone.owner -end - -function cfxOwnedZones.getEnemyZonesFor(aCoalition) - local enemyZones = {} - local ourEnemy = dcsCommon.getEnemyCoalitionFor(aCoalition) - for zKey, aZone in pairs(cfxOwnedZones.zones) do - if aZone.owner == ourEnemy then -- only check enemy owned zones - -- note: will include untargetable zones - table.insert(enemyZones, aZone) - end - end - return enemyZones -end - -function cfxOwnedZones.getNearestOwnedZoneToPoint(aPoint) - local shortestDist = math.huge - local closestZone = nil - - for zKey, aZone in pairs(cfxOwnedZones.zones) do - local zPoint = cfxZones.getPoint(aZone) - currDist = dcsCommon.dist(zPoint, aPoint) - if aZone.untargetable ~= true and - currDist < shortestDist then - shortestDist = currDist - closestZone = aZone - end - end - - return closestZone, shortestDist -end - -function cfxOwnedZones.getNearestOwnedZone(theZone) - local shortestDist = math.huge - local closestZone = nil - local aPoint = cfxZones.getPoint(theZone) - for zKey, aZone in pairs(cfxOwnedZones.zones) do - local zPoint = cfxZones.getPoint(aZone) - currDist = dcsCommon.dist(zPoint, aPoint) - if aZone.untargetable ~= true and currDist < shortestDist then - shortestDist = currDist - closestZone = aZone - end - end - - return closestZone, shortestDist -end - -function cfxOwnedZones.getNearestEnemyOwnedZone(theZone, targetNeutral) - if not targetNeutral then targetNeutral = false else targetNeutral = true end - local shortestDist = math.huge - local closestZone = nil - local ourEnemy = dcsCommon.getEnemyCoalitionFor(theZone.owner) - if not ourEnemy then return nil end -- we called for a neutral zone. they have no enemies - local zPoint = cfxZones.getPoint(theZone) - - for zKey, aZone in pairs(cfxOwnedZones.zones) do - if targetNeutral then - -- return all zones that do not belong to us - if aZone.owner ~= theZone.owner then - local aPoint = cfxZones.getPoint(aZone) - currDist = dcsCommon.dist(aPoint, zPoint) - if aZone.untargetable ~= true and currDist < shortestDist then - shortestDist = currDist - closestZone = aZone - end - end - else - -- return zones that are taken by the Enenmy - if aZone.owner == ourEnemy then -- only check own zones - local aPoint = cfxZones.getPoint(aZone) - currDist = dcsCommon.dist(zPoint, aPoint) - if aZone.untargetable ~= true and currDist < shortestDist then - shortestDist = currDist - closestZone = aZone - end - end - end - end - - return closestZone, shortestDist -end - -function cfxOwnedZones.getNearestFriendlyZone(theZone, targetNeutral) - if not targetNeutral then targetNeutral = false else targetNeutral = true end - local shortestDist = math.huge - local closestZone = nil - local ourEnemy = dcsCommon.getEnemyCoalitionFor(theZone.owner) - if not ourEnemy then return nil end -- we called for a neutral zone. they have no enemies nor friends, all zones would be legal. - local zPoint = cfxZones.getPoint(theZone) - for zKey, aZone in pairs(cfxOwnedZones.zones) do - if targetNeutral then - -- target all zones that do not belong to the enemy - if aZone.owner ~= ourEnemy then - local aPoint = cfxZones.getPoint(aZone) - currDist = dcsCommon.dist(zPoint, aPoint) - if aZone.untargetable ~= true and currDist < shortestDist then - shortestDist = currDist - closestZone = aZone - end - end - else - -- only target zones that are taken by us - if aZone.owner == theZone.owner then -- only check own zones - local aPoint = cfxZones.getPoint(aZone) - currDist = dcsCommon.dist(zPoint, aPoint) - if aZone.untargetable ~= true and currDist < shortestDist then - shortestDist = currDist - closestZone = aZone - end - end - end - end - - return closestZone, shortestDist -end - -function cfxOwnedZones.enemiesRemaining(aZone) - if cfxOwnedZones.getNearestEnemyOwnedZone(aZone) then return true end - return false -end - -function cfxOwnedZones.spawnAttackTroops(theTypes, aZone, aCoalition, aFormation) - local unitTypes = {} -- build type names - -- split theTypes into an array of types - unitTypes = dcsCommon.splitString(theTypes, ",") - if #unitTypes < 1 then - table.insert(unitTypes, "Soldier M4") -- make it one m4 trooper as fallback - -- simply exit, no troops specified - if cfxOwnedZones.verbose then - trigger.action.outText("+++owdZ: no attackers for " .. aZone.name .. ". exiting", 30) - end - return - end - - if cfxOwnedZones.verbose then - trigger.action.outText("+++owdZ: spawning attackers for " .. aZone.name, 30) - end - - --local theCountry = dcsCommon.coalition2county(aCoalition) - - local spawnPoint = {x = aZone.point.x, y = aZone.point.y, z = aZone.point.z} -- copy struct - - local rads = aZone.attackPhi * 0.01745 - spawnPoint.x = spawnPoint.x + math.cos(aZone.attackPhi) * aZone.attackDelta - spawnPoint.y = spawnPoint.y + math.sin(aZone.attackPhi) * aZone.attackDelta - - local spawnZone = cfxZones.createSimpleZone("attkSpawnZone", spawnPoint, aZone.attackRadius) - - local theGroup, theData = cfxZones.createGroundUnitsInZoneForCoalition ( - aCoalition, -- theCountry, - aZone.name .. " (A) " .. dcsCommon.numberUUID(), -- must be unique - spawnZone, - unitTypes, - aFormation, -- outward facing - 0) - return theGroup, theData -end - -function cfxOwnedZones.spawnDefensiveTroops(theTypes, aZone, aCoalition, aFormation) - local unitTypes = {} -- build type names - -- split theTypes into an array of types - unitTypes = dcsCommon.splitString(theTypes, ",") - if #unitTypes < 1 then - table.insert(unitTypes, "Soldier M4") -- make it one m4 trooper as fallback - -- simply exit, no troops specified - if cfxOwnedZones.verbose then - trigger.action.outText("+++owdZ: no defenders for " .. aZone.name .. ". exiting", 30) - end - return - end - - --local theCountry = dcsCommon.coalition2county(aCoalition) - local spawnZone = cfxZones.createSimpleZone("spawnZone", aZone.point, aZone.spawnRadius) - local theGroup, theData = cfxZones.createGroundUnitsInZoneForCoalition ( - aCoalition, --theCountry, - aZone.name .. " (D) " .. dcsCommon.numberUUID(), -- must be unique - spawnZone, unitTypes, - aFormation, -- outward facing - 0) - return theGroup, theData -end -- -- U P D A T E -- -function cfxOwnedZones.sendOutAttackers(aZone) - -- only spawn if there are zones to attack - if not cfxOwnedZones.enemiesRemaining(aZone) then - if cfxOwnedZones.verbose then - trigger.action.outText("+++owdZ - no enemies, resting ".. aZone.name, 30) - end - return - end - - if cfxOwnedZones.verbose then - trigger.action.outText("+++owdZ - attack cycle for ".. aZone.name, 30) - end - -- load the attacker typestring - - -- step one: get the attackers - local attackers = aZone.attackersRED; - if (aZone.owner == 2) then attackers = aZone.attackersBLUE end - - if attackers == "none" then return end - - local theGroup, theData = cfxOwnedZones.spawnAttackTroops(attackers, aZone, aZone.owner, aZone.attackFormation) - - local troopData = {} - troopData.groupData = theData - troopData.orders = "attackOwnedZone" -- lazy coding! - troopData.side = aZone.owner - cfxOwnedZones.spawnedAttackers[theData.name] = troopData - - -- submit them to ground troops handler as zoneseekers - -- and our groundTroops module will handle the rest - if cfxGroundTroops then - local troops = cfxGroundTroops.createGroundTroops(theGroup) - troops.orders = "attackOwnedZone" - troops.side = aZone.owner - cfxGroundTroops.addGroundTroopsToPool(troops) -- hand off to ground troops - else - if cfxOwnedZones.verbose then - trigger.action.outText("+++ Owned Zones: no ground troops module on send out attackers", 30) - end - end -end - --- bang support - function cfxOwnedZones.bangNeutral(value) if not cfxOwnedZones.neutralTriggerFlag then return end local newVal = trigger.misc.getUserFlag(cfxOwnedZones.neutralTriggerFlag) + value @@ -616,286 +237,7 @@ function cfxOwnedZones.zoneConquered(aZone, theSide, formerOwner) -- 0 = neutral -- update map cfxOwnedZones.drawZoneInMap(aZone) -- update status in map. will erase previous version - -- remove all defenders to avoid shock state - aZone.defenders = nil - -- change to captured - - aZone.state = "captured" - aZone.timeStamp = timer.getTime() -end - -function cfxOwnedZones.repairDefenders(aZone) - --trigger.action.outText("+++ enter repair for ".. aZone.name, 30) - -- find a unit that is missing from my typestring and replace it - -- one by one until we are back to full strength - -- step one: get the defenders and create a type array - local defenders = aZone.defendersRED; - if (aZone.owner == 2) then defenders = aZone.defendersBLUE end - local unitTypes = {} -- build type names - - -- if none, we are done - if defenders == "none" then return end - - -- split theTypes into an array of types - allTypes = dcsCommon.trimArray( - dcsCommon.splitString(defenders, ",") - ) - local livingTypes = {} -- init to emtpy, so we can add to it if none are alive - if (aZone.defenders) then - -- some remain. add one of the killed - livingTypes = dcsCommon.getGroupTypes(aZone.defenders) - -- we now iterate over the living types, and remove their - -- counterparts from the allTypes. We then take the first that - -- is left - - if #livingTypes > 0 then - for key, aType in pairs (livingTypes) do - if not dcsCommon.findAndRemoveFromTable(allTypes, aType) then - trigger.action.outText("+++OwdZ WARNING: found unmatched type <" .. aType .. "> while trying to repair defenders for ".. aZone.name, 30) - else - -- all good - end - end - end - end - - -- when we get here, allTypes is reduced to those that have been killed - if #allTypes < 1 then - trigger.action.outText("+++owdZ: WARNING: all types exist when repairing defenders for ".. aZone.name, 30) - else - table.insert(livingTypes, allTypes[1]) -- we simply use the first that we find - end - -- remove the old defenders - if aZone.defenders then - aZone.defenders:destroy() - end - - -- now livingTypes holds the full array of units we need to spawn - local theCountry = dcsCommon.getACountryForCoalition(aZone.owner) - local spawnZone = cfxZones.createSimpleZone("spawnZone", aZone.point, aZone.spawnRadius) - local theGroup, theData = cfxZones.createGroundUnitsInZoneForCoalition ( - aZone.owner, -- was wrongly: theCountry - aZone.name .. dcsCommon.numberUUID(), -- must be unique - spawnZone, - livingTypes, - - aZone.formation, -- outward facing - 0) - aZone.defenders = theGroup - aZone.lastDefenders = theGroup:getSize() -end - -function cfxOwnedZones.inShock(aZone) - -- a unit was destroyed, everyone else is in shock, no rerpairs - -- group can re-shock when another unit is destroyed -end - -function cfxOwnedZones.spawnDefenders(aZone) - local defenders = aZone.defendersRED; - - if (aZone.owner == 2) then defenders = aZone.defendersBLUE end - -- before we spawn new defenders, remove the old ones - if aZone.defenders then - if aZone.defenders:isExist() then - aZone.defenders:destroy() - end - aZone.defenders = nil - end - - -- if 'none', simply exit - if defenders == "none" then return end - - local theGroup, theData = cfxOwnedZones.spawnDefensiveTroops(defenders, aZone, aZone.owner, aZone.formation) - -- the troops reamin, so no orders to move, no handing off to ground troop manager - aZone.defenders = theGroup - aZone.defenderData = theData -- used for persistence - if theGroup then - --aZone.defenderMax = theGroup:getInitialSize() -- so we can determine if some units were destroyed - aZone.lastDefenders = theGroup:getInitialSize() --- aZone.defenderMax -- if this is larger than current number, someone bit the dust - --trigger.action.outText("+++ spawned defenders for ".. aZone.name, 30) - else - trigger.action.outText("+++owdZ: WARNING: spawned no defenders for ".. aZone.name, 30) - aZone.defenderData = nil - end -end - --- --- per-zone update, run down the FSM to determine what to do. --- FSM uses timeStamp since when state was set. Possible states are --- - init -- has just been inited for the first time. will usually immediately produce defenders, --- and then transition to defending --- - catured -- has just been captured. transition to defending --- - defending -- wait until timer has reached goal, then produce defending units and transition to attacking. --- - attacking -- wait until timer has reached goal, and then produce attacking units and send them to closest enemy zone. --- state is interrupted as soon as a defensive unit is lost. state then goes to defending with timer starting --- - idle - do nothing, zone's actions are turned off --- - shocked -- a unit was destroyed. group is in shock for a time until it starts repairing. If another unit is --- destroyed during the shocked period, the timer resets to zero and repairs are delayed --- - repairing -- as long as we aren't at full strength, units get replaced one by one until at full strength --- each time the timer counts down, another missing unit is replaced, and all other unit's health --- is reset to 100% --- --- a Zone with the paused attribute set to true will cause it to not do anything --- --- check if defenders are specified -function cfxOwnedZones.usesDefenders(aZone) - if aZone.owner == 0 then return false end - local defenders = aZone.defendersRED; - if (aZone.owner == 2) then defenders = aZone.defendersBLUE end - - return defenders ~= "none" -end - -function cfxOwnedZones.usesAttackers(aZone) - if aZone.owner == 0 then return false end - local attackers = aZone.attackersRED; - if (aZone.owner == 2) then defenders = aZone.attackersBLUE end - - return attackers ~= "none" -end - -function cfxOwnedZones.updateZone(aZone) - -- a zone can be paused, causing it to not progress anything - -- even if zone status is still init, will NOT produce anything - -- if paused is on. - if aZone.paused then return end - - nextState = aZone.state; - - -- first, check if my defenders have been attacked and one of them has been killed - -- if so, we immediately switch to 'shocked' - if cfxOwnedZones.usesDefenders(aZone) and - aZone.defenders then - -- we have defenders - if aZone.defenders:isExist() then - -- isee if group was damaged - if not aZone.lastDefenders then - -- fresh group, probably from persistence, needs init - aZone.lastDefenders = -1 - end - if aZone.defenders:getSize() < aZone.lastDefenders then - -- yes, at least one unit destroyed - aZone.timeStamp = timer.getTime() - aZone.lastDefenders = aZone.defenders:getSize() - if aZone.lastDefenders == 0 then - aZone.defenders = nil - end - aZone.state = "shocked" - - return - else - aZone.lastDefenders = aZone.defenders:getSize() - end - - else - -- group was destroyed. erase link, and go into shock for the last time - aZone.state = "shocked" - aZone.timeStamp = timer.getTime() - aZone.lastDefenders = 0 - aZone.defenders = nil - - return - end - end - - - if aZone.state == "init" then - -- during init we instantly create the defenders since - -- we assume the zone existed already - if aZone.owner > 0 then - cfxOwnedZones.spawnDefenders(aZone) - -- now drop into attacking mode to produce attackers - nextState = "attacking" - else - nextState = "idle" - end - - aZone.timeStamp = timer.getTime() - - elseif aZone.state == "idle" then - -- nothing to do, zone is effectively switched off. - -- used for neutal zones or when forced to turn off - -- in some special cases - - elseif aZone.state == "captured" then - -- start the clock on defenders - nextState = "defending" - aZone.timeStamp = timer.getTime() - if cfxOwnedZones.verbose then - trigger.action.outText("+++owdZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) - end - elseif aZone.state == "defending" then - if timer.getTime() > aZone.timeStamp + cfxOwnedZones.defendingTime then - cfxOwnedZones.spawnDefenders(aZone) - -- now drop into attacking mode to produce attackers - nextState = "attacking" - aZone.timeStamp = timer.getTime() - if cfxOwnedZones.verbose then - trigger.action.outText("+++owdZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) - end - end - - elseif aZone.state == "repairing" then - -- we are currently rebuilding defenders unit by unit - if timer.getTime() > aZone.timeStamp + cfxOwnedZones.repairTime then - aZone.timeStamp = timer.getTime() - -- wait's up, repair one defender, then check if full strength - cfxOwnedZones.repairDefenders(aZone) - -- see if we are full strenght and if so go to attack, else set timer to reair the next unit - if aZone.defenders and aZone.defenders:isExist() and aZone.defenders:getSize() >= aZone.defenders:getInitialSize() then - -- we are at max size, time to produce some attackers - -- progress to next state - nextState = "attacking" - aZone.timeStamp = timer.getTime() - if cfxOwnedZones.verbose then - trigger.action.outText("+++owdZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) - end - end - - end - - elseif aZone.state == "shocked" then - -- we are currently rebuilding defenders unit by unit - if timer.getTime() > aZone.timeStamp + cfxOwnedZones.shockTime then - nextState = "repairing" - aZone.timeStamp = timer.getTime() - if cfxOwnedZones.verbose then - trigger.action.outText("+++owdZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) - end - end - - elseif aZone.state == "attacking" then - if timer.getTime() > aZone.timeStamp + cfxOwnedZones.attackingTime then - cfxOwnedZones.sendOutAttackers(aZone) - -- reset timer - aZone.timeStamp = timer.getTime() - if cfxOwnedZones.verbose then - trigger.action.outText("+++owdZ: State " .. aZone.state .. " reset for " .. aZone.name, 30) - end - end - else - -- unknown zone state - end - aZone.state = nextState -end - -function cfxOwnedZones.GC() - -- GC run. remove all my dead remembered troops - local before = #cfxOwnedZones.spawnedAttackers - local filteredAttackers = {} - for gName, gData in pairs (cfxOwnedZones.spawnedAttackers) do - -- all we need to do is get the group of that name - -- and if it still returns units we are fine - local gameGroup = Group.getByName(gName) - if gameGroup and gameGroup:isExist() and gameGroup:getSize() > 0 then - filteredAttackers[gName] = gData - end - end - cfxOwnedZones.spawnedAttackers = filteredAttackers - if cfxOwnedZones.verbose then - trigger.action.outText("owned zones GC ran: before <" .. before .. ">, after <" .. #cfxOwnedZones.spawnedAttackers .. ">", 30) - end end function cfxOwnedZones.update() @@ -951,9 +293,15 @@ function cfxOwnedZones.update() -- trigger.action.outText(theZone.name .. " blue: " .. theZone.numBlue .. " red " .. theZone.numRed, 30) local lastOwner = theZone.owner local newOwner = 0 -- neutral is default + if theZone.unbeatable then -- Parker Lewis can't lose. Neither this zone. + newOwner = lastOwner + end + -- determine new owner - -- step one: no troops here. Become neutral? - if theZone.numRed < 1 and theZone.numBlue < 1 then + if theZone.unbeatable then + -- we do nothing + elseif theZone.numRed < 1 and theZone.numBlue < 1 then + -- no troops here. Become neutral? if cfxOwnedZones.numKeep < 1 then newOwner = lastOwner -- keep it, else turns neutral else @@ -1018,71 +366,13 @@ function cfxOwnedZones.update() end theZone.owner = newOwner - -- production & flags - -- see if pause/unpause was issued - -- note that capping a zone will not change pause status - if theZone.pauseFlag and cfxZones.testZoneFlag(theZone, theZone.pauseFlag, theZone.ownedTriggerMethod, "lastPauseValue") then - theZone.paused = true - end - - if theZone.activateFlag and cfxZones.testZoneFlag(theZone, theZone.activateFlag, theZone.ownedTriggerMethod, "lastActivateValue") then - theZone.paused = false - end - -- update ownership flag if exists if theZone.ownedBy then cfxZones.setFlagValue(theZone.ownedBy, theZone.owner, theZone) end - -- now, perhaps with their new owner call updateZone() - -- to calcualte production for this zone - cfxOwnedZones.updateZone(theZone) end -- iterating all zones -end - -function cfxOwnedZones.updateOLD() - cfxOwnedZones.updateSchedule = timer.scheduleFunction(cfxOwnedZones.updateOLD, {}, timer.getTime() + 1/cfxOwnedZones.ups) - - -- iterate all zones, and determine their current ownership status - for key, aZone in pairs(cfxOwnedZones.zones) do - -- a hand change can only happen if there are only ground troops from the OTHER side in - -- the zone - local categ = Group.Category.GROUND - local theBlues = cfxZones.groupsOfCoalitionPartiallyInZone(2, aZone, categ) - local theReds = cfxZones.groupsOfCoalitionPartiallyInZone(1, aZone, categ) - local currentOwner = aZone.owner - if #theBlues > 0 and #theReds == 0 and aZone.unbeatable ~= true then - -- this now belongs to blue - if currentOwner ~= 2 then - cfxOwnedZones.zoneConquered(aZone, 2, currentOwner) - end - elseif #theBlues == 0 and #theReds > 0 and aZone.unbeatable ~= true then - -- this now belongs to red - if currentOwner ~= 1 then - cfxOwnedZones.zoneConquered(aZone, 1, currentOwner) - end - end - - -- see if pause/unpause was issued - -- note that capping a zone will not change pause status - if aZone.pauseFlag and cfxZones.testZoneFlag(aZone, aZone.pauseFlag, aZone.ownedTriggerMethod, "lastPauseValue") then - aZone.paused = true - end - - if aZone.activateFlag and cfxZones.testZoneFlag(aZone, aZone.activateFlag, aZone.ownedTriggerMethod, "lastActivateValue") then - aZone.paused = false - end - - -- now, perhaps with their new owner call updateZone() - cfxOwnedZones.updateZone(aZone) - end - -end - -function cfxOwnedZones.houseKeeping() - timer.scheduleFunction(cfxOwnedZones.houseKeeping, {}, timer.getTime() + 5 * 60) -- every 5 minutes - cfxOwnedZones.GC() end function cfxOwnedZones.sideOwnsAll(theSide) @@ -1116,37 +406,17 @@ function cfxOwnedZones.saveData() -- iterate all my zones and create data for idx, theZone in pairs(cfxOwnedZones.zones) do local zoneData = {} - if theZone.defenderData then - zoneData.defenderData = dcsCommon.clone(theZone.defenderData) - dcsCommon.synchGroupData(zoneData.defenderData) - end + if theZone.conqueredFlag then zoneData.conquered = cfxZones.getFlagValue(theZone.conqueredFlag, theZone) end + zoneData.owner = theZone.owner - zoneData.state = theZone.state -- will prevent immediate spawn - -- since new zones are spawned with 'init' allZoneData[theZone.name] = zoneData end -- now iterate all attack groups that we have spawned and that -- (maybe) are still alive - cfxOwnedZones.GC() -- start with a GC run to remove all dead - local livingAttackers = {} - for gName, gData in pairs (cfxOwnedZones.spawnedAttackers) do - -- all we need to do is get the group of that name - -- and if it still returns units we are fine - -- spawnedAttackers is a [groupName] table with {.groupData, .orders, .side} - local gameGroup = Group.getByName(gName) - if gameGroup and gameGroup:isExist() then - if gameGroup:getSize() > 0 then - local sData = dcsCommon.clone(gData) - dcsCommon.synchGroupData(sData.groupData) - livingAttackers[gName] = sData - end - end - end - -- now write the info for the flags that we output for #red, etc local flagInfo = {} flagInfo.neutral = cfxZones.getFlagValue(cfxOwnedZones.neutralTriggerFlag, cfxOwnedZones) @@ -1154,7 +424,6 @@ function cfxOwnedZones.saveData() flagInfo.blue = cfxZones.getFlagValue(cfxOwnedZones.blueTriggerFlag, cfxOwnedZones) -- assemble the data theData.zoneData = allZoneData - theData.attackers = livingAttackers theData.flagInfo = flagInfo -- return it @@ -1180,19 +449,7 @@ function cfxOwnedZones.loadData() -- access zone local theZone = cfxOwnedZones.getOwnedZoneByName(zName) if theZone then - if zData.defenderData then - if theZone.defenders and theZone.defenders:isExist() then - -- should not happen, but so be it - theZone.defenders:destroy() - end - local gData = zData.defenderData - local cty = gData.cty - local cat = gData.cat - theZone.defenders = coalition.addGroup(cty, cat, gData) - theZone.defenderData = zData.defenderData - end theZone.owner = zData.owner - theZone.state = zData.state if zData.conquered then cfxZones.setFlagValue(theZone.conqueredFlag, zData.conquered, theZone) end @@ -1203,27 +460,6 @@ function cfxOwnedZones.loadData() end end - -- now process all attackers - local allAttackers = theData.attackers - for gName, gdTroop in pairs(allAttackers) do - -- table is {.groupData, .orders, .side} - local gData = gdTroop.groupData - local orders = gdTroop.orders - local side = gdTroop.side - local cty = gData.cty - local cat = gData.cat - -- add to my own attacker queue so we can save later - local dClone = dcsCommon.clone(gdTroop) - cfxOwnedZones.spawnedAttackers[gName] = dClone - local theGroup = coalition.addGroup(cty, cat, gData) - if cfxGroundTroops then - local troops = cfxGroundTroops.createGroundTroops(theGroup) - troops.orders = orders - troops.side = side - cfxGroundTroops.addGroundTroopsToPool(troops) -- hand off to ground troops - end - end - -- now process module global flags local flagInfo = theData.flagInfo if flagInfo then @@ -1242,19 +478,10 @@ function cfxOwnedZones.readConfigZone(theZone) cfxOwnedZones.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) cfxOwnedZones.announcer = cfxZones.getBoolFromZoneProperty(theZone, "announcer", true) --- if cfxZones.hasProperty(theZone, "r!") then - cfxOwnedZones.redTriggerFlag = cfxZones.getStringFromZoneProperty(theZone, "r!", "*") --- end --- if cfxZones.hasProperty(theZone, "b!") then - cfxOwnedZones.blueTriggerFlag = cfxZones.getStringFromZoneProperty(theZone, "b!", "*") --- end --- if cfxZones.hasProperty(theZone, "n!") then - cfxOwnedZones.neutralTriggerFlag = cfxZones.getStringFromZoneProperty(theZone, "n!", "*") --- end - cfxOwnedZones.defendingTime = cfxZones.getNumberFromZoneProperty(theZone, "defendingTime", 100) - cfxOwnedZones.attackingTime = cfxZones.getNumberFromZoneProperty(theZone, "attackingTime", 300) - cfxOwnedZones.shockTime = cfxZones.getNumberFromZoneProperty(theZone, "shockTime", 200) - cfxOwnedZones.repairTime = cfxZones.getNumberFromZoneProperty(theZone, "repairTime", 200) + cfxOwnedZones.redTriggerFlag = cfxZones.getStringFromZoneProperty(theZone, "r!", "*") + cfxOwnedZones.blueTriggerFlag = cfxZones.getStringFromZoneProperty(theZone, "b!", "*") + cfxOwnedZones.neutralTriggerFlag = cfxZones.getStringFromZoneProperty(theZone, "n!", "*") + -- numKeep, numCap, fastEval, easyContest cfxOwnedZones.numCap = cfxZones.getNumberFromZoneProperty(theZone, "numCap", 1) -- minimal number of units required to cap zone cfxOwnedZones.numKeep = cfxZones.getNumberFromZoneProperty(theZone, "numKeep", 0) -- number required to keep zone @@ -1299,11 +526,7 @@ function cfxOwnedZones.init() initialized = true cfxOwnedZones.updateSchedule = timer.scheduleFunction(cfxOwnedZones.update, {}, timer.getTime() + 1/cfxOwnedZones.ups) - -- start housekeeping - cfxOwnedZones.houseKeeping() - trigger.action.outText("cx/x owned zones v".. cfxOwnedZones.version .. " started", 30) - return true end diff --git a/modules/cfxPlayerScore.lua b/modules/cfxPlayerScore.lua index 31dfb45..d6682c0 100644 --- a/modules/cfxPlayerScore.lua +++ b/modules/cfxPlayerScore.lua @@ -1,8 +1,10 @@ cfxPlayerScore = {} -cfxPlayerScore.version = "1.5.1" +cfxPlayerScore.version = "2.0.0" +cfxPlayerScore.name = "cfxPlayerScore" -- compatibility with flag bangers cfxPlayerScore.badSound = "Death BRASS.wav" cfxPlayerScore.scoreSound = "Quest Snare 3.wav" cfxPlayerScore.announcer = true +cfxPlayerScore.firstSave = true -- to force overwrite --[[-- VERSION HISTORY 1.0.1 - bug fixes to killDetected 1.0.2 - messaging clean-up, less verbose @@ -31,6 +33,43 @@ cfxPlayerScore.announcer = true - feats API - logFeatForPlayer(playerName, theFeat, coa) 1.5.1 - init feats before reading + 2.0.0 - sound name for good and bad corrected + - scoreOnly option + - saveScore? input in config + - incremental option in config for save + - preProcessor() added land and birth for players + - addSafeZone() + - landing score possible as feat + - also detect landing and birth events for players + - birth zeroes deferred scores and feats + - delayAfterLanding property + - delayBetweenLandings to filter landings and + space them properly from take offs + - ranking option + - scoreOnly option + - scoreFileName option + - update() loop + - read "scoreSafe" zones + - scoreaccu in playerScore + - landing feat added, enabled with landing > 0 score attribute + - delayed awards after landing + - save score to file + - show score to all + - feat zones + - awardLimit - feats can be limited in number total + - wildcard support for feature + - kill zones - limit kill score awards to inside zones + - feats can be limited to once per player + - persistence: featNum + - persistence: awardedTo + - always schedule + - hasAward logic + - unit2player + - detect player plane death + - ffMod attribute + - pkMod attribute + - pvp feat + - immediate awarding of all negative scores, even if deferred --]]-- @@ -40,11 +79,18 @@ cfxPlayerScore.requiredLibs = { } cfxPlayerScore.playerScore = {} -- init to empty cfxPlayerScore.deferred = false -- on deferred, we only award after landing, and erase on any form of re-slot +cfxPlayerScore.delayAfterLanding = 10 -- seconds after landing +cfxPlayerScore.safeZones = {} -- safe zones to land in +cfxPlayerScore.featZones = {} -- zones that define feats +cfxPlayerScore.killZones = {} -- when set, kills only count here + + -- typeScore: dictionary sorted by typeString for score -- extend to add more types. It is used by unitType2score to -- determine the base unit score cfxPlayerScore.typeScore = {} - +cfxPlayerScore.lastPlayerLanding = {} -- timestamp, by player name +cfxPlayerScore.delayBetweenLandings = 30 -- seconds to count as separate landings, also set during take-off to prevent janky t/o to count. -- -- we subscribe to the kill event. each time a unit -- is killed, we check if it was killed by a player @@ -56,7 +102,171 @@ cfxPlayerScore.helo = 40 cfxPlayerScore.ground = 10 cfxPlayerScore.ship = 80 cfxPlayerScore.train = 5 +cfxPlayerScore.landing = 0 -- if > 0 it scores as feat +cfxPlayerScore.unit2player = {} -- lookup and reverse look-up +--cfxPlayerScore.player2unit = {} -- to detect death and destruction + +function cfxPlayerScore.addSafeZone(theZone) + theZone.scoreSafe = cfxZones.getCoalitionFromZoneProperty(theZone, "scoreSafe", 0) + table.insert(cfxPlayerScore.safeZones, theZone) +end + +function cfxPlayerScore.addKillZone(theZone) + theZone.killZone = cfxZones.getCoalitionFromZoneProperty(theZone, "killZone", 0) -- value currently ignored + theZone.duet = cfxZones.getBoolFromZoneProperty(theZone, "duet", false) -- does killer have to be in zone? + table.insert(cfxPlayerScore.killZones, theZone) +end + +function cfxPlayerScore.addFeatZone(theZone) + theZone.coalition = cfxZones.getCoalitionFromZoneProperty(theZone, "feat", 0) -- who can earn, 0 for all sides + theZone.featType = cfxZones.getStringFromZoneProperty(theZone, "featType", "kill") + theZone.featType = string.upper(theZone.featType) + if theZone.featType == "LAND" then theZone.featType = "LANDING" end + if theZone.featType ~= "KILL" and + theZone.featType ~= "LANDING" and + theZone.featType ~= "PVP" + then + theZone.featType = "KILL" + end + theZone.featDesc = cfxZones.getStringFromZoneProperty(theZone, "description", "(some feat)") + theZone.featNum = cfxZones.getNumberFromZoneProperty(theZone, "awardLimit", -1) -- how many times this can be awarded, -1 is infinite + theZone.ppOnce = cfxZones.getBoolFromZoneProperty(theZone, "awardOnce", false) + theZone.awardedTo = {} -- by player name: true/false + table.insert(cfxPlayerScore.featZones, theZone) + if cfxPlayerScore.verbose then + trigger.action.outText("+++ feat zone <" .. theZone.name .. "> read: [" .. theZone.featDesc .. "] for <" .. theZone.featType .. ">", 30) + end +end + +function cfxPlayerScore.getFeatByName(name) + for idx, theZone in pairs(cfxPlayerScore.featZones) do + if name == theZone.name then return theZone end + end + return nil +end + +function cfxPlayerScore.featsForLocation(name, loc, coa, featType, killer, victim) + if not loc then return {} end + -- loc is location of landing unit for landing + -- and location of victim for kill + -- coa is coalition of landing unit + -- and coalition of killer for kill +-- trigger.action.outText("enter feat check for <" .. featType .. ">", 30) + if not coa then coa = 0 end + if not featType then featType = "KILL" end + featType = string.upper(featType) + local theFeats = {} + for idx, theZone in pairs(cfxPlayerScore.featZones) do +-- trigger.action.outText("featcheck: <" .. theZone.name .. ">", 30) + local canAward = true + + -- check if it can be awarded + if theZone.featNum == 0 then + canAward = false +-- trigger.action.outText(" - failed featNum", 30) + end + + if theZone.featType ~= featType then + canAward = false + --trigger.action.outText(" - failed type check (look for <" .. featType .. ">, got <" .. theZone.featType .. ">", 30) + end + + if not (theZone.coalition == 0 or theZone.coalition == coa) then + canAward = false + -- trigger.action.outText(" - failed coa check ", 30) + end + + if featType == "PVP" then + -- make sure kill is pvp kill + if not victim then canAward = false + elseif not victim.getPlayerName then + canAward = false + elseif not victim:getPlayerName() then + canAward = false + end + end + + if not cfxZones.pointInZone(loc, theZone) then + canAward = false + -- trigger.action.outText(" - failed loc check ", 30) + end + + if theZone.ppOnce then + if theZone.awardedTo[name] then + canAward = false + --trigger.action.outText(" - already awarded fail ", 30) + end + end + + if canAward then + table.insert(theFeats, theZone) -- jupp, add it + --trigger.action.outText(" can award", 30) + else + --trigger.action.outText("FAIL.", 30) + end + + end + return theFeats +end + +function cfxPlayerScore.preprocessWildcards(inMsg, aUnit, aVictim) + local theMsg = inMsg + if not aVictim then aVictim = aUnit end + local pName = "Unknown" + if aUnit then + if aUnit.getPlayerName then + pN = aUnit:getPlayerName() + if pN then pName = pN end + end + theMsg = theMsg:gsub("", aUnit:getName()) + theMsg = theMsg:gsub("", aUnit:getTypeName()) + theMsg = theMsg:gsub("", aUnit:getGroup():getName()) + end + theMsg = theMsg:gsub("", pName) + if aVictim then + -- if player killed, get killed player's name else use unknown AI + if aVictim.getPlayerName then + pkName = aVictim:getPlayerName() + if pkName then + theMsg = theMsg:gsub("", pkName) + else + theMsg = theMsg:gsub("", "unknown AI") + end + end + theMsg = theMsg:gsub("", aVictim:getName()) + theMsg = theMsg:gsub("", aVictim:getTypeName()) + -- victim may not have group. guard against that + -- happens if unit 'cooks off' + --local gName = "(unknown)" + local aGroup = nil + if aVictim.getGroup then + aVictim:getGroup() + end + if aGroup and aGroup.getName then + theMsg = theMsg:gsub("", aGroup:getName()) + else + theMsg = theMsg:gsub("", "(Unknown)") + end + end + return theMsg +end + +function cfxPlayerScore.evalFeatDescription(name, theZone, playerUnit, victim) + local msg = theZone.featDesc + if not victim then victim = playerUnit end + -- eval wildcards + msg = cfxPlayerScore.preprocessWildcards(msg, playerUnit, victim) + msg = cfxZones.processStringWildcards(msg, theZone) -- nil time format, nil imperial, nil responses + + -- update featNum since it's been 'used' + if theZone.featNum > 0 then + theZone.featNum = theZone.featNum -1 + end + -- mark this feat awarded to player, only relevant for ppOnce + theZone.awardedTo[name] = true + return msg +end function cfxPlayerScore.cat2BaseScore(inCat) if inCat == 0 then return cfxPlayerScore.aircraft end -- airplane @@ -152,6 +362,7 @@ function cfxPlayerScore.getPlayerScore(playerName) thePlayerScore = {} thePlayerScore.name = playerName thePlayerScore.score = 0 -- score + thePlayerScore.scoreaccu = 0 -- for deferred thePlayerScore.killTypes = {} -- the type strings killed, dict thePlayerScore.killQueue = {} -- when using deferred thePlayerScore.totalKills = 0 -- number of kills total @@ -166,22 +377,32 @@ function cfxPlayerScore.setPlayerScore(playerName, thePlayerScore) cfxPlayerScore.playerScore[playerName] = thePlayerScore end -function cfxPlayerScore.updateScoreForPlayer(playerName, score) +-- will never defer +function cfxPlayerScore.updateScoreForPlayerImmediate(playerName, score) local thePlayerScore = cfxPlayerScore.getPlayerScore(playerName) - thePlayerScore.score = thePlayerScore.score + score cfxPlayerScore.setPlayerScore(playerName, thePlayerScore) return thePlayerScore.score end -function cfxPlayerScore.logKillForPlayer(playerName, theUnit) - if not theUnit then return end - if not playerName then return end - - local thePlayerScore = cfxPlayerScore.getPlayerScore(playerName) - - local theType = theUnit:getTypeName() - local killCount = thePlayerScore.killTypes[theType] +function cfxPlayerScore.updateScoreForPlayer(playerName, score) + -- main update score + if cfxPlayerScore.deferred then -- just queue it + local thePlayerScore = cfxPlayerScore.getPlayerScore(playerName) + thePlayerScore.scoreaccu = thePlayerScore.scoreaccu + score + cfxPlayerScore.setPlayerScore(playerName, thePlayerScore) -- write-through. why? because it may be a new entry. + return thePlayerScore.score -- this is the old score!!! + end + --local thePlayerScore = cfxPlayerScore.getPlayerScore(playerName) + --thePlayerScore.score = thePlayerScore.score + score + --cfxPlayerScore.setPlayerScore(playerName, thePlayerScore) + --return thePlayerScore.score + -- now write immediately + return cfxPlayerScore.updateScoreForPlayerImmediate(playerName, score) +end + +function cfxPlayerScore.doLogTypeKill(playerName, thePlayerScore, theType) + local killCount = thePlayerScore.killTypes[theType] if killCount == nil then killCount = 0 end @@ -190,13 +411,27 @@ function cfxPlayerScore.logKillForPlayer(playerName, theUnit) thePlayerScore.killTypes[theType] = killCount cfxPlayerScore.setPlayerScore(playerName, thePlayerScore) +end + +function cfxPlayerScore.logKillForPlayer(playerName, theUnit) + -- main kill type /total count logging, can be deferred + -- no score change here + if not theUnit then return end + if not playerName then return end + local thePlayerScore = cfxPlayerScore.getPlayerScore(playerName) + local theType = theUnit:getTypeName() + + if cfxPlayerScore.deferred then + -- just queue it + table.insert(thePlayerScore.killQueue, theType) + cfxPlayerScore.setPlayerScore(playerName, thePlayerScore) -- write-through. why? because it may be a new entry. + return + end + + cfxPlayerScore.doLogTypeKill(playerName, thePlayerScore, theType) end -function cfxPlayerScore.logFeatForPlayer(playerName, theFeat, coa) - if not theFeat then return end - if not playerName then return end - -- access player's record. will alloc if new by itself - local thePlayerScore = cfxPlayerScore.getPlayerScore(playerName) +function cfxPlayerScore.doLogFeat(playerName, thePlayerScore, theFeat) if not thePlayerScore.featTypes then thePlayerScore.featTypes = {} end local featCount = thePlayerScore.featTypes[theFeat] if featCount == nil then @@ -206,37 +441,79 @@ function cfxPlayerScore.logFeatForPlayer(playerName, theFeat, coa) thePlayerScore.totalFeats = thePlayerScore.totalFeats + 1 thePlayerScore.featTypes[theFeat] = featCount - if coa then - trigger.action.outTextForCoalition(coa, playerName .. " achieved " .. theFeat, 30) - trigger.action.outSoundForCoalition(coa, cfxPlayerScore.scoreSound) - end cfxPlayerScore.setPlayerScore(playerName, thePlayerScore) + end -function cfxPlayerScore.playerScore2text(thePlayerScore) - local desc = thePlayerScore.name .. " - score: ".. thePlayerScore.score .. " - kills: " .. thePlayerScore.totalKills .. "\n" - -- now go through all killSide - if dcsCommon.getSizeOfTable(thePlayerScore.killTypes) < 1 then - desc = desc .. " - NONE -\n" +function cfxPlayerScore.logFeatForPlayer(playerName, theFeat, coa) + -- usually called externally with theFeat being a string. no + -- scoring is passed + if not theFeat then return end + if not playerName then return end + -- access player's record. will alloc if new by itself + + if coa then + local disclaim = "" + if cfxPlayerScore.deferred then disclaim = " (award pending)" end + trigger.action.outTextForCoalition(coa, playerName .. " achieved " .. theFeat .. disclaim, 30) + trigger.action.outSoundForCoalition(coa, cfxPlayerScore.scoreSound) end - for theType, quantity in pairs(thePlayerScore.killTypes) do - desc = desc .. " - " .. theType .. ": " .. quantity .. "\n" + + local thePlayerScore = cfxPlayerScore.getPlayerScore(playerName) + if cfxPlayerScore.deferred then + table.insert(thePlayerScore.featQueue, theFeat) + cfxPlayerScore.setPlayerScore(playerName, thePlayerScore) + return end + cfxPlayerScore.doLogFeat(playerName, thePlayerScore, theFeat) +end + +function cfxPlayerScore.playerScore2text(thePlayerScore, scoreOnly) + if not scoreOnly then scoreOnly = false end + local desc = thePlayerScore.name .. " statistics:\n" + + if cfxPlayerScore.reportScore then + desc = desc .. " - score: ".. thePlayerScore.score .. " - total kills: " .. thePlayerScore.totalKills .. "\n" + if scoreOnly then + return desc + end + + -- now go through all kills + desc = desc .. "\nKills by type:\n" + if dcsCommon.getSizeOfTable(thePlayerScore.killTypes) < 1 then + desc = desc .. " - NONE -\n" + end + for theType, quantity in pairs(thePlayerScore.killTypes) do + desc = desc .. " - " .. theType .. ": " .. quantity .. "\n" + end + end + -- now enumerate all feats if not thePlayerScore.featTypes then thePlayerScore.featTypes = {} end + if cfxPlayerScore.reportFeats then + desc = desc .. "\n Accomplishments:\n" + if dcsCommon.getSizeOfTable(thePlayerScore.featTypes) < 1 then + desc = desc .. " - NONE -\n" + end + for theFeat, quantity in pairs(thePlayerScore.featTypes) do + desc = desc .. " - " .. theFeat + if quantity > 1 then + desc = desc .. " (x" .. quantity .. ")" + end + desc = desc .. "\n" + end + end - desc = desc .. "\nOther Accomplishments:\n" - if dcsCommon.getSizeOfTable(thePlayerScore.featTypes) < 1 then - desc = desc .. " - NONE -\n" + if cfxPlayerScore.reportScore and thePlayerScore.scoreaccu > 0 then + desc = desc .. "\n - unclaimed score: " .. thePlayerScore.scoreaccu .."\n" end - for theFeat, quantity in pairs(thePlayerScore.featTypes) do - desc = desc .. " - " .. theFeat - if quantity > 1 then - desc = desc .. " (x" .. quantity .. ")" - end - desc = desc .. "\n" + + local featCount = dcsCommon.getSizeOfTable(thePlayerScore.featQueue) + if cfxPlayerScore.reportFeats and featCount > 0 then + desc = desc .. " - unclaimed feats: " .. featCount .."\n" end + return desc end @@ -245,6 +522,34 @@ function cfxPlayerScore.scoreTextForPlayerNamed(playerName) return cfxPlayerScore.playerScore2text(thePlayerScore) end +function cfxPlayerScore.scoreTextForAllPlayers(ranked) + if not ranked then ranked = false end + local theText = "" + local isFirst = true + local theScores = cfxPlayerScore.playerScore + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr: Saving score - <" .. dcsCommon.getSizeOfTable(theScores) .. "> entries.", 30) + end + if ranked then + table.sort(theScores, function(left, right) return left.score < right.score end ) + end + local rank = 1 + for name, score in pairs(theScores) do + if not isFirst then + theText = theText .. "\n" + end + if ranked then + if rank < 10 then theText = theText .. " " end + theText = theText .. rank .. ". " + end + theText = theText .. cfxPlayerScore.playerScore2text(score, cfxPlayerScore.scoreOnly) + isFirst = false + rank = rank + 1 + end + return theText +end + + function cfxPlayerScore.isNamedUnit(theUnit) if not theUnit then return false end local theName = "(cfx_none)" @@ -263,27 +568,76 @@ function cfxPlayerScore.isNamedUnit(theUnit) end function cfxPlayerScore.awardScoreTo(killSide, theScore, killerName) - local playerScore = cfxPlayerScore.updateScoreForPlayer(killerName, theScore) + local playerScore + if theScore < 0 then + playerScore = cfxPlayerScore.updateScoreForPlayerImmediate(killerName, theScore) + else + playerScore = cfxPlayerScore.updateScoreForPlayer(killerName, theScore) + end - if cfxPlayerScore.announcer then - trigger.action.outTextForCoalition(killSide, "Killscore: " .. theScore .. " for a total of " .. playerScore .. " for " .. killerName, 30) + if not cfxPlayerScore.reportScore then return end + + if cfxPlayerScore.announcer then + if (theScore > 0) and cfxPlayerScore.deferred then + thePlayerRecord = cfxPlayerScore.getPlayerScore(killerName) -- re-read after write + trigger.action.outTextForCoalition(killSide, "Killscore: " .. theScore .. ", now " .. thePlayerRecord.scoreaccu .. " waiting for " .. killerName .. ", awarded after landing", 30) + else -- negative score or not deferred + trigger.action.outTextForCoalition(killSide, "Killscore: " .. theScore .. " for a total of " .. playerScore .. " for " .. killerName, 30) + end end end + -- -- EVENT HANDLING -- +function cfxPlayerScore.linkUnitWithPlayer(theUnit) + -- create the entries for lookup and reverseLooup tables + local uName = theUnit:getName() + local pName = theUnit:getPlayerName() + cfxPlayerScore.unit2player[uName] = pName + --cfxPlayerScore.player2unit[pName] = uName -- is this needed? +end + +function cfxPlayerScore.unlinkUnit(theUnit) + local uName = theUnit:getName() + cfxPlayerScore.unit2player[uName] = nil +end + function cfxPlayerScore.preProcessor(theEvent) -- return true if the event should be processed -- by us + if theEvent.initiator == nil then + return false + end + + -- check if this was FORMERLY a player plane + local theUnit = theEvent.initiator + local uName = theUnit:getName() + if cfxPlayerScore.unit2player[uName] then + -- this requires special IMMEDIATE handling when event is + -- one of the below + if theEvent.id == 5 or -- crash + theEvent.id == 8 or -- dead + theEvent.id == 9 or -- pilot_dead + theEvent.id == 30 or -- unit loss + theEvent.id == 6 then -- eject + -- these can lead to a pilot demerit + --trigger.action.outText("PREPROC plane player extra event - possible death", 30) + -- event does NOT have a player + cfxPlayerScore.handlePlayerDeath(theEvent) + return false + end + end + + -- initiator must be player + if not theUnit.getPlayerName or + not theUnit:getPlayerName() then + return false + end if theEvent.id == 28 then -- we only are interested in kill events where - -- there is an initiator, and the initiator is - -- a player - if theEvent.initiator == nil then - return false - end - + -- there is a target local killer = theEvent.initiator if theEvent.target == nil then if cfxPlayerScore.verbose then @@ -292,9 +646,68 @@ function cfxPlayerScore.preProcessor(theEvent) return false end - local wasPlayer = dcsCommon.isPlayerUnit(killer) - return wasPlayer + -- if there are kill zones, we filter all kills that happen outside of kill zones + if #cfxPlayerScore.killZones > 0 then + local pLoc = theUnit:getPoint() + local tLoc = theEvent.target:getPoint() + local isIn, percent, dist, theZone = cfxZones.pointInOneOfZones(tLoc, cfxPlayerScore.killZones) + + if not isIn then + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr: kill detected, but target <" .. theEvent.target:getName() .. "> was outside of any kill zones", 30) + end + return false + end + + if theZone.duet and not cfxZones.pointInZone(pLoc, theZone) then + -- player must be in same zone but was not + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr: kill detected, but player <" .. theUnit:getPlayerName() .. "> was outside of kill zone <" .. theZone.name .. ">", 30) + end + return false + end + end + return true end + + -- birth event for players initializes score if + -- not existed, and nils the queue + if theEvent.id == 15 then + -- player birth + -- link player and unit + cfxPlayerScore.linkUnitWithPlayer(theUnit) + return true + end + + -- take off. overwrites timestamp for last landing + -- so a blipping t/o does nor count. Pre-proc only + if theEvent.id == 3 then + local now = timer.getTime() + local playerName = theUnit:getPlayerName() + cfxPlayerScore.lastPlayerLanding[playerName] = now -- overwrite + return false + end + + -- landing can score. but only the first landing in x seconds + -- landing in safe zone promotes any queued scores to + -- permanent if enabled, then nils queue + if theEvent.id == 4 then + -- player landed. filter multiple landed events + local now = timer.getTime() + local playerName = theUnit:getPlayerName() + local lastLanding = cfxPlayerScore.lastPlayerLanding[playerName] + cfxPlayerScore.lastPlayerLanding[playerName] = now -- overwrite + if lastLanding and lastLanding + cfxPlayerScore.delayBetweenLandings > now then + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr: Player <" .. playerName .. "> touch-down ignored: too soon.", 30) + trigger.action.outText("now is <" .. now .. ">, between is <" .. cfxPlayerScore.delayBetweenLandings .. ">, last + between is <" .. lastLanding + cfxPlayerScore.delayBetweenLandings .. ">", 30) + end + -- filter this event + return false + end + return true + end + return false end @@ -309,11 +722,34 @@ function cfxPlayerScore.isStaticObject(theUnit) return true end +function cfxPlayerScore.checkKillFeat(name, killer, victim, fratricide) + if not fratricide then fratricide = false end + local theLoc = victim:getPoint() -- vic's loc is relevant for zone check + local coa = killer:getCoalition() + + local killFeats = cfxPlayerScore.featsForLocation(name, theLoc, coa,"KILL", killer, victim) + + if (not fratricide) and #killFeats > 0 then + + -- use the feat description + -- we may want to use closest, currently simply the first + theFeatZone = killFeats[1] + local desc = cfxPlayerScore.evalFeatDescription(name, theFeatZone, killer, victim) -- updates awardedTo + + cfxPlayerScore.logFeatForPlayer(name, desc, playerSide) + theScore = cfxPlayerScore.getPlayerScore(name) -- re-read after write + + if cfxPlayerScore.verbose then + trigger.action.outText("Kill feat awarded/queued for <" .. name .. ">", 30) + end + end + +end + function cfxPlayerScore.killDetected(theEvent) -- we are only getting called when and if -- a kill occured and killer was a player -- and target exists --- trigger.action.outText("KILL EVENT", 30) local killer = theEvent.initiator local killerName = killer:getPlayerName() if not killerName then killerName = "" end @@ -335,11 +771,12 @@ function cfxPlayerScore.killDetected(theEvent) if staticScore > 0 then trigger.action.outSoundForCoalition(killSide, cfxPlayerScore.scoreSound) cfxPlayerScore.awardScoreTo(killSide, staticScore, killerName) + cfxPlayerScore.checkKillFeat(killerName, killer, victim, false) end return end - -- was it fraternicide? + -- was it fratricide? local vicSide = victim:getCoalition() local fraternicide = killSide == vicSide local vicDesc = victim:getTypeName() @@ -355,11 +792,11 @@ function cfxPlayerScore.killDetected(theEvent) local staticName = victim:getName() -- on statics, this returns -- name as entered in TOP LINE local staticScore = cfxPlayerScore.object2score(victim) --- trigger.action.outText("KILL STATIC with score " .. staticScore, 30) + if staticScore > 0 then -- this was a named static, return the score - unless our own if fraternicide then - scoreMod = -1 * scoreMod -- blue on blue static kills award negative + scoreMod = cfxPlayerScore.ffMod * scoreMod -- blue on blue static kill trigger.action.outSoundForCoalition(killSide, cfxPlayerScore.badSound) else trigger.action.outSoundForCoalition(killSide, cfxPlayerScore.scoreSound) @@ -371,6 +808,11 @@ function cfxPlayerScore.killDetected(theEvent) -- no score, no mentions end + if not fraternicide then + cfxPlayerScore.checkKillFeat(killerName, killer, victim, false) + end + + return end @@ -389,6 +831,7 @@ function cfxPlayerScore.killDetected(theEvent) -- see which weapon was used. gun kills score 2x local killMeth = "" local killWeap = theEvent.weapon + --[[-- if killWeap then local killWeapType = killWeap:getCategory() if killWeapType == 0 then @@ -399,14 +842,16 @@ function cfxPlayerScore.killDetected(theEvent) killMeth = " with " .. kWeapon end end - + --]]-- if pk then vicDesc = victim:getPlayerName() .. " in " .. vicDesc - scoreMod = scoreMod * 10 + scoreMod = scoreMod * cfxPlayerScore.pkMod end + + -- if fratricide, times ffMod (friedlyFire) if fraternicide then - scoreMod = scoreMod * -2 + scoreMod = scoreMod * cfxPlayerScore.ffMod ---2 if cfxPlayerScore.announcer then trigger.action.outTextForCoalition(killSide, killerName .. " in " .. killVehicle .. " killed FRIENDLY " .. vicDesc .. killMeth .. "!", 30) trigger.action.outSoundForCoalition(killSide, cfxPlayerScore.badSound) @@ -418,7 +863,7 @@ function cfxPlayerScore.killDetected(theEvent) trigger.action.outSoundForCoalition(killSide, cfxPlayerScore.scoreSound) end -- since not fraticide, log this kill - -- logging kills does not impct score + -- logging kills does not impact score cfxPlayerScore.logKillForPlayer(killerName, victim) end @@ -430,8 +875,287 @@ function cfxPlayerScore.killDetected(theEvent) end local totalScore = unitScore * scoreMod + -- if the score is negative, awardScoreTo will automatically + -- make it immediate, else depending on deferred cfxPlayerScore.awardScoreTo(killSide, totalScore, killerName) + if not fraternicide then + -- only award kill feats for kills of the enemy + cfxPlayerScore.checkKillFeat(killerName, killer, victim, false) + end + +end + +function cfxPlayerScore.handlePlayerLanding(theEvent) + local thePlayerUnit = theEvent.initiator + local theLoc = thePlayerUnit:getPoint() + local playerSide = thePlayerUnit:getCoalition() + local playerName = thePlayerUnit:getPlayerName() + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr: Player <" .. playerName .. "> landed", 30) + end + + local theScore = cfxPlayerScore.getPlayerScore(playerName) + + -- see if a feat is available for this landing + local landingFeats = cfxPlayerScore.featsForLocation(playerName, theLoc, playerSide,"LANDING") + + -- first, scheck if landing is awardable, and if so, + -- award the landing + if cfxPlayerScore.landing > 0 or #landingFeats > 0 then + -- yes, landings are awarded a score. do before + -- resolving any queues + desc = "Landed" + if #landingFeats > 0 then + -- use the feat description + -- we may want to use closest, currently simply the first + theFeatZone = landingFeats[1] + desc = cfxPlayerScore.evalFeatDescription(playerName, theFeatZone, thePlayerUnit) -- nil victim, defaults to player + + else + if theEvent.place then + desc = desc .. " successfully (" .. theEvent.place:getName() .. ")" + else + desc = desc .. " aircraft" + end + end + + cfxPlayerScore.updateScoreForPlayer(playerName, cfxPlayerScore.landing) + cfxPlayerScore.logFeatForPlayer(playerName, desc, playerSide) + theScore = cfxPlayerScore.getPlayerScore(playerName) -- re-read after write + if cfxPlayerScore.verbose then + trigger.action.outText("Landing feat awarded/queued for <" .. playerName .. ">", 30) + end + end + + -- see if we are using deferred scoring, else can end right now + if not cfxPlayerScore.deferred then + return + end + -- only continue if there is anything to award + local killSize = dcsCommon.getSizeOfTable(theScore.killQueue) + local featSize = dcsCommon.getSizeOfTable(theScore.featQueue) + --trigger.action.outText("+++pScr: kS = <" .. killSize .. ">, fS = <" .. featSize .. ">, Accu = <" .. theScore.scoreaccu .. ">", 30) + + -- to avoid possible race conditions with other modules that + -- trigger on landing, we always schedule the check in 10 seconds + --[[-- + if killSize < 1 and + featSize < 1 and + theScore.scoreaccu < 1 then + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr: deferred and nothing to award after touchdown to <" .. playerName .. ">, returning", 30) + end + return + end + --]]-- + + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr: prepping deferred score for <" .. playerName ..">", 30) + end + + -- see if player landed in a scoreSafe zone + local theUnit = thePlayerUnit + local loc = theUnit:getPoint() + local uid = theUnit:getID() + local coa = theUnit:getCoalition() + local isSafe = false + for idx, theZone in pairs(cfxPlayerScore.safeZones) do + if theZone.scoreSafe == 0 or theZone.scoreSafe == coa then + if cfxZones.pointInZone(loc, theZone) then + isSafe = true + end + end + end + + if not isSafe then + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr: deferred, but not inside score safe zone.", 30) + end + return + end + + trigger.action.outTextForUnit(uid, playerName .. ", please wait in safe zone to claim pending score/feats (" .. cfxPlayerScore.delayAfterLanding .. " seconds).", 30) + + local unitName = theUnit:getName() + local args = {playerName, unitName} + timer.scheduleFunction(cfxPlayerScore.scheduledAward, args, timer.getTime() + cfxPlayerScore.delayAfterLanding) +end + +function cfxPlayerScore.scheduledAward(args) + -- called with player name and unit name in args + local playerName = args[1] + local unitName = args[2] + + local theUnit = Unit.getByName(unitName) + if not theUnit or + not Unit.isExist(theUnit) + then + -- unit is gone + trigger.action.outText("Player <" .. playerName .. "> lost score.", 30) + return + end + + local uid = theUnit:getID() + if theUnit:inAir() then + trigger.action.outTextForUnit(uid, "Can't award score to <" .. playerName .. ">: unit not on the ground.", 30) + return + end + + if theUnit:getLife() < 1 then + trigger.action.outTextForUnit(uid, "Can't award score to <" .. playerName .. ">: unit did not survive landing.", 30) + return -- needs to reslot, don't have to nil player score + end + + -- see if player is *still* within a scoreSafe zone + local loc = theUnit:getPoint() + local coa = theUnit:getCoalition() + local isSafe = false + for idx, theZone in pairs(cfxPlayerScore.safeZones) do + if theZone.scoreSafe == 0 or theZone.scoreSafe == coa then + if cfxZones.pointInZone(loc, theZone) then + isSafe = true + end + end + end + + if not isSafe then + trigger.action.outTextForUnit(uid, "Can't award score for <" .. playerName .. ">, not in safe zone.", 30) + return + end + + + local theScore = cfxPlayerScore.getPlayerScore(playerName) + if dcsCommon.getSizeOfTable(theScore.killQueue) < 1 and + dcsCommon.getSizeOfTable(theScore.featQueue) < 1 and + theScore.scoreaccu < 1 then + -- player changed planes or + -- there was nothing to award + trigger.action.outTextForUnit(uid, "Thank you, " .. playerName .. ", no scores or feats pending.", 30) + return + end + + local hasAward = false + + -- when we get here we award all scores, kills, and feats + local desc = "\nPlayer " .. playerName .. " is awarded:\n" + -- score and total score + if theScore.scoreaccu > 0 then + theScore.score = theScore.score + theScore.scoreaccu + desc = desc .. " score: " .. theScore.scoreaccu .. " for a new total of " .. theScore.score .. "\n" + theScore.scoreaccu = 0 + hasAward = true + end + + if cfxPlayerScore.verbose then + trigger.action.outText("Iterating kill q <" .. dcsCommon.getSizeOfTable(theScore.killQueue) .. "> and feat q <" .. dcsCommon.getSizeOfTable(theScore.featQueue) .. ">", 30) + end + -- iterate kill type list + if dcsCommon.getSizeOfTable(theScore.killQueue) > 0 then + desc = desc .. " confirmed kills in order:\n" + for idx, theType in pairs(theScore.killQueue) do + desc = desc .. " " .. theType .. "\n" + cfxPlayerScore.doLogTypeKill(playerName, theScore, theType) + end + hasAward = true + end + theScore.killQueue = {} + + -- iterate feats + if dcsCommon.getSizeOfTable(theScore.featQueue) > 0 then + desc = desc .. " confirmed feats:\n" + for idx, theFeat in pairs(theScore.featQueue) do + desc = desc .. " " .. theFeat .. "\n" + cfxPlayerScore.doLogFeat(playerName, theScore, theFeat) + end + hasAward = true + end + theScore.featQueue = {} + + -- output score + desc = desc .. "\n" + if hasAward then + trigger.action.outTextForCoalition(coa, desc, 30) + end +end + +function cfxPlayerScore.handlePlayerDeath(theEvent) + -- multiple of these events can occur per player + -- so we use the unit2player link to see player + -- is affected, and if so, erase the link so it + -- only counts once + local theUnit = theEvent.initiator + local uName = theUnit:getName() + + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr: LOA/player death handler entry for <" .. uName .. ">", 30) + end + + local pName = cfxPlayerScore.unit2player[uName] + if pName then + -- this was a player name with link still live. + if cfxPlayerScore.planeLoss ~= 0 then + -- plane loss has IMMEDIATE consequences + cfxPlayerScore.updateScoreForPlayerImmediate(pName, cfxPlayerScore.planeLoss) + if cfxPlayerScore.announcer then + local uid = theUnit:getID() + local thePlayerRecord = cfxPlayerScore.getPlayerScore(pName) + trigger.action.outTextForUnit(uid, "Loss of aircraft detected: " .. cfxPlayerScore.planeLoss .. " awarded immediately, for new total of " .. thePlayerRecord.score, 30) + end + end + -- always clear the link. + cfxPlayerScore.unit2player[uName] = nil + else + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr - no action for LOA", 30) + end + end + +end + +function cfxPlayerScore.handlePlayerEvent(theEvent) + if theEvent.id == 28 then + -- kill from player detected. + cfxPlayerScore.killDetected(theEvent) + + elseif theEvent.id == 15 then -- birth + -- access player score for player. this will + -- allocate if doesn't exist. Any player ever + -- birthed will be in db + local thePlayerUnit = theEvent.initiator + local playerSide = thePlayerUnit:getCoalition() + local playerName = thePlayerUnit:getPlayerName() + local theScore = cfxPlayerScore.getPlayerScore(playerName) + -- now re-init feat and score queues + if theScore.scoreaccu > 0 then + trigger.action.outTextForCoalition(playerSide, "Player " .. playerName .. ", score of <" .. theScore.scoreaccu .. "> points discarded.", 30) + end + theScore.scoreaccu = 0 + if dcsCommon.getSizeOfTable(theScore.killQueue) > 0 then + trigger.action.outTextForCoalition(playerSide, "Player " .. playerName .. ", <" .. dcsCommon.getSizeOfTable(theScore.killQueue) .. "> kills discarded.", 30) + end + theScore.killQueue = {} + if dcsCommon.getSizeOfTable(theScore.featQueue) > 0 then + trigger.action.outTextForCoalition(playerSide, "Player " .. playerName .. ", <" .. dcsCommon.getSizeOfTable(theScore.featQueue) .. "> feats discarded.", 30) + end + theScore.featQueue = {} + -- write back + cfxPlayerScore.setPlayerScore(playerName, theScore) + + elseif theEvent.id == 4 then -- land + -- see if plane is still connected to player + local theUnit = theEvent.initiator + local uName = theUnit:getName() + if cfxPlayerScore.unit2player[uName] then + -- is filtered if too soon after last take-off/landing + cfxPlayerScore.handlePlayerLanding(theEvent) + else + if verbose then + trigger.action.outText("+++pScr: filtered landing for <" .. uName .. ">: player no longer linked to unit", 30) + end + end + + end end function cfxPlayerScore.readConfigZone(theZone) @@ -442,24 +1166,66 @@ function cfxPlayerScore.readConfigZone(theZone) cfxPlayerScore.ground = cfxZones.getNumberFromZoneProperty(theZone, "ground", 10) cfxPlayerScore.ship = cfxZones.getNumberFromZoneProperty(theZone, "ship", 80) cfxPlayerScore.train = cfxZones.getNumberFromZoneProperty(theZone, "train", 5) + cfxPlayerScore.landing = cfxZones.getNumberFromZoneProperty(theZone, "landing", 0) -- if > 0 then feat + + cfxPlayerScore.pkMod = cfxZones.getNumberFromZoneProperty(theZone, "pkMod", 1) -- factor for killing a player + cfxPlayerScore.ffMod = cfxZones.getNumberFromZoneProperty(theZone, "ffMod", -2) -- factor for friendly fire + cfxPlayerScore.planeLoss = cfxZones.getNumberFromZoneProperty(theZone, "planeLoss", -10) -- points added when player's plane crashes cfxPlayerScore.announcer = cfxZones.getBoolFromZoneProperty(theZone, "announcer", true) if cfxZones.hasProperty(theZone, "badSound") then - cfxReconMode.badSound = cfxZones.getStringFromZoneProperty(theZone, "badSound", "") + cfxPlayerScore.badSound = cfxZones.getStringFromZoneProperty(theZone, "badSound", "") end if cfxZones.hasProperty(theZone, "scoreSound") then - cfxReconMode.scoreSound = cfxZones.getStringFromZoneProperty(theZone, "scoreSound", "") + cfxPlayerScore.scoreSound = cfxZones.getStringFromZoneProperty(theZone, "scoreSound", "") end + + -- triggering saving scores + if cfxZones.hasProperty(theZone, "saveScore?") then + cfxPlayerScore.saveScore = cfxZones.getStringFromZoneProperty(theZone, "saveScore?", "none") + cfxPlayerScore.lastSaveScore = trigger.misc.getUserFlag(cfxPlayerScore.saveScore) + cfxPlayerScore.incremental = cfxZones.getBoolFromZoneProperty(theZone, "incremental", false) -- incremental saves + end + + -- triggering show all scores + if cfxZones.hasProperty(theZone, "showScore?") then + cfxPlayerScore.showScore = cfxZones.getStringFromZoneProperty(theZone, "showScore?", "none") + cfxPlayerScore.lastShowScore = trigger.misc.getUserFlag(cfxPlayerScore.showScore) + end + + cfxPlayerScore.rankPlayers = cfxZones.getBoolFromZoneProperty(theZone, "rankPlayers", false) + + cfxPlayerScore.scoreOnly = cfxZones.getBoolFromZoneProperty(theZone, "scoreOnly", true) + + cfxPlayerScore.deferred = cfxZones.getBoolFromZoneProperty(theZone, "deferred", false) + + cfxPlayerScore.delayAfterLanding = cfxZones.getNumberFromZoneProperty(theZone, "delayAfterLanding", 10) + + cfxPlayerScore.scoreFileName = cfxZones.getStringFromZoneProperty(theZone, "scoreFileName", "Player Scores") + + cfxPlayerScore.reportScore = cfxZones.getBoolFromZoneProperty(theZone, "reportScore", true) + + cfxPlayerScore.reportFeats = cfxZones.getBoolFromZoneProperty(theZone, "reportFeats", true) end -- --- load / save +-- load / save (game data) -- function cfxPlayerScore.saveData() local theData = {} + -- save current score list. simple clone local theScore = dcsCommon.clone(cfxPlayerScore.playerScore) theData.theScore = theScore + -- build feat zone list + local featZones = {} + for idx, theZone in pairs(cfxPlayerScore.featZones) do + local theFeat = {} + theFeat.awardedTo = theZone.awardedTo + theFeat.featNum = theZone.featNum + featZones[theZone.name] = theFeat + end + theData.featData = featZones return theData end @@ -475,14 +1241,103 @@ function cfxPlayerScore.loadData() local theScore = theData.theScore cfxPlayerScore.playerScore = theScore - + local featData = theData.featData + if featData then + for name, data in pairs(featData) do + local theZone = cfxPlayerScore.getFeatByName(name) + if theZone then + theZone.awardedTo = data.awardedTo + theZone.featNum = data.featNum + end + end + end end +-- +-- save scores (text file) +-- +function cfxPlayerScore.saveScores(theText, name) + if not _G["persistence"] then + trigger.action.outText("+++pScr: persistence module required to save scores. Here are the scores that I would have saved to <" .. name .. ">:\n", 30) + trigger.action.outText(theText, 30) + return + end + + if not persistence.active then + trigger.action.outText("+++pScr: persistence module can't write. Please ensure that you have desanitized lfs and io for DCS", 30) + return + end + + local append = cfxPlayerScore.incremental + local shared = false -- currently not supported + + if cfxPlayerScore.incremental then + if cfxPlayerScore.firstSave then + theText = "\n*** NEW MISSION started.\n" .. theText + end + + -- prepend time for score + theText = "\n\n====== Mission Time: " .. dcsCommon.nowstring() .. "\n" .. theText + end + + if persistence.saveText(theText, name, shared, append) then + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr: scores saved to <" .. persistence.missionDir .. name .. ">", 30) + end + else + trigger.action.outText("+++pScr: unable to save scores to <" .. persistence.missionDir .. name .. ">") + end + + cfxPlayerScore.firstSave = false +end + +function cfxPlayerScore.saveScoreToFile() + -- local built score table + local ranked = cfxPlayerScore.rankPlayers + local theText = cfxPlayerScore.scoreTextForAllPlayers(ranked) + + -- save to disk + cfxPlayerScore.saveScores(theText, cfxPlayerScore.scoreFileName) +end + +function cfxPlayerScore.showScoreToAll() + local ranked = cfxPlayerScore.rankPlayers + local theText = cfxPlayerScore.scoreTextForAllPlayers(ranked) + trigger.action.outText(theText, 30) +end + +-- +-- Update +-- +function cfxPlayerScore.update() + -- re-invoke in 1 second + timer.scheduleFunction(cfxPlayerScore.update, {}, timer.getTime() + 1) + + -- see if someone banged on saveScore + if cfxPlayerScore.saveScore then + if cfxZones.testZoneFlag(cfxPlayerScore, cfxPlayerScore.saveScore, "change", "lastSaveScore") then + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr: saving scores...", 30) + end + cfxPlayerScore.saveScoreToFile() + end + end + + -- showScore perhaps? + if cfxPlayerScore.showScore then + if cfxZones.testZoneFlag(cfxPlayerScore, cfxPlayerScore.showScore, "change", "lastShowScore") then + if cfxPlayerScore.verbose then + trigger.action.outText("+++pScr: showing scores...", 30) + end + cfxPlayerScore.showScoreToAll() + end + end + +end -- -- start -- - function cfxPlayerScore.start() if not dcsCommon.libCheck("cfx Player Score", cfxPlayerScore.requiredLibs) @@ -510,8 +1365,31 @@ function cfxPlayerScore.start() trigger.action.outText("+++scr: read config", 30) end + -- read all scoreSafe zones + local safeZones = cfxZones.zonesWithProperty("scoreSafe") + for k, aZone in pairs(safeZones) do + cfxPlayerScore.addSafeZone(aZone) + end + + -- read all feat zones + local featZones = cfxZones.zonesWithProperty("feat") + for k, aZone in pairs(featZones) do + cfxPlayerScore.addFeatZone(aZone) + end + + -- read all kill zones + local killZones = cfxZones.zonesWithProperty("killZone") + for k, aZone in pairs(killZones) do + cfxPlayerScore.addKillZone(aZone) + end + + -- check that deferred has scoreSafe zones + if cfxPlayerScore.deferred and dcsCommon.getSizeOfTable(cfxPlayerScore.safeZones) < 1 then + trigger.action.outText("+++pScr: WARNING - deferred scoring active but no 'scoreSafe' zones set!", 30) + end + -- subscribe to events and use dcsCommon's handler structure - dcsCommon.addEventHandler(cfxPlayerScore.killDetected, + dcsCommon.addEventHandler(cfxPlayerScore.handlePlayerEvent, cfxPlayerScore.preProcessor, cfxPlayerScore.postProcessor) @@ -525,7 +1403,10 @@ function cfxPlayerScore.start() -- now load my data cfxPlayerScore.loadData() end - + + -- start update + cfxPlayerScore.update() + trigger.action.outText("cfxPlayerScore v" .. cfxPlayerScore.version .. " started", 30) return true end @@ -538,4 +1419,19 @@ end -- TODO: score mod for weapons type -- TODO: player kill score + +--[[-- + +feat zone +"feat" feat type, default is kill, possible other types + - landing +score zones + - zones outside of which no scoring counts, but feats are still ok + +- add take off feats + +can be extended with other, standalone feat modules that follow the +same pattern, e.g. enter a zone, detect someone + +--]]-- \ No newline at end of file diff --git a/modules/cfxPlayerScoreUI.lua b/modules/cfxPlayerScoreUI.lua index 4afcf1e..65309a6 100644 --- a/modules/cfxPlayerScoreUI.lua +++ b/modules/cfxPlayerScoreUI.lua @@ -1,294 +1,87 @@ cfxPlayerScoreUI = {} -cfxPlayerScoreUI.version = "1.0.3" +cfxPlayerScoreUI.version = "2.0.0" +cfxPlayerScoreUI.verbose = false + --[[-- VERSION HISTORY - 1.0.2 - initial version - 1.0.3 - module check - + - 2.0.0 - removed cfxPlayer dependency, handles own commands --]]-- --- WARNING: REQUIRES cfxPlayerScore to work. --- WARNING: ASSUMES SINGLE_PLAYER GROUPS! -cfxPlayerScoreUI.requiredLibs = { - "cfxPlayerScore", -- this is doing score keeping - "cfxPlayer", -- player events, comms -} --- find & command cfxGroundTroops-based jtacs --- UI installed via OTHER for all groups with players --- module based on xxxGrpUI and jtacUI - -cfxPlayerScoreUI.groupConfig = {} -- all inited group private config data -cfxPlayerScoreUI.simpleCommands = true -- if true, f10 other invokes directly - --- --- C O N F I G H A N D L I N G --- ============================= --- --- Each group has their own config block that can be used to --- store group-private data and configuration items. --- - -function cfxPlayerScoreUI.resetConfig(conf) -end - -function cfxPlayerScoreUI.createDefaultConfig(theGroup) - local conf = {} - conf.theGroup = theGroup - conf.name = theGroup:getName() - conf.id = theGroup:getID() - conf.coalition = theGroup:getCoalition() - - cfxPlayerScoreUI.resetConfig(conf) - - conf.mainMenu = nil; -- this is where we store the main menu if we branch - conf.myCommands = nil; -- this is where we store the commands if we branch - - return conf -end - --- getConfigFor group will allocate if doesn't exist in DB --- and add to it -function cfxPlayerScoreUI.getConfigForGroup(theGroup) - if not theGroup then - trigger.action.outText("+++WARNING: cfxPlayerScoreUI nil group in getConfigForGroup!", 30) - return nil - end - local theName = theGroup:getName() - local c = cfxPlayerScoreUI.getConfigByGroupName(theName) -- we use central accessor - if not c then - c = cfxPlayerScoreUI.createDefaultConfig(theGroup) - cfxPlayerScoreUI.groupConfig[theName] = c -- should use central accessor... - end - return c -end - -function cfxPlayerScoreUI.getConfigByGroupName(theName) -- DOES NOT allocate when not exist - if not theName then return nil end - return cfxPlayerScoreUI.groupConfig[theName] -end - - -function cfxPlayerScoreUI.getConfigForUnit(theUnit) - -- simple one-off step by accessing the group - if not theUnit then - trigger.action.outText("+++WARNING: cfxPlayerScoreUI nil unit in getConfigForUnit!", 30) - return nil - end - - local theGroup = theUnit:getGroup() - return getConfigForGroup(theGroup) -end - --- --- --- M E N U H A N D L I N G --- ========================= --- --- - function cfxPlayerScoreUI.clearCommsSubmenus(conf) - if conf.myCommands then - for i=1, #conf.myCommands do - missionCommands.removeItemForGroup(conf.id, conf.myCommands[i]) - end - end - conf.myCommands = {} -end - -function cfxPlayerScoreUI.removeCommsFromConfig(conf) - cfxPlayerScoreUI.clearCommsSubmenus(conf) - - if conf.myMainMenu then - missionCommands.removeItemForGroup(conf.id, conf.myMainMenu) - conf.myMainMenu = nil - end -end - --- this only works in single-unit groups. may want to check if group --- has disappeared -function cfxPlayerScoreUI.removeCommsForUnit(theUnit) - if not theUnit then return end - if not theUnit:isExist() then return end - -- perhaps add code: check if group is empty - local conf = cfxPlayerScoreUI.getConfigForUnit(theUnit) - cfxPlayerScoreUI.removeCommsFromConfig(conf) -end - -function cfxPlayerScoreUI.removeCommsForGroup(theGroup) - if not theGroup then return end - if not theGroup:isExist() then return end - local conf = cfxPlayerScoreUI.getConfigForGroup(theGroup) - cfxPlayerScoreUI.removeCommsFromConfig(conf) -end - --- --- set main root in F10 Other. All sub menus click into this --- ---function cfxPlayerScoreUI.isEligibleForMenu(theGroup) --- return true ---end - -function cfxPlayerScoreUI.setCommsMenuForUnit(theUnit) - if not theUnit then - trigger.action.outText("+++WARNING: cfxPlayerScoreUI nil UNIT in setCommsMenuForUnit!", 30) - return - end - if not theUnit:isExist() then return end - - local theGroup = theUnit:getGroup() - cfxPlayerScoreUI.setCommsMenu(theGroup) -end - -function cfxPlayerScoreUI.setCommsMenu(theGroup) - -- depending on own load state, we set the command structure - -- it begins at 10-other, and has 'grpUI' as main menu with submenus - -- as required - if not theGroup then return end - if not theGroup:isExist() then return end - - -- we test here if this group qualifies for - -- the menu. if not, exit - --if not cfxPlayerScoreUI.isEligibleForMenu(theGroup) then return end - - local conf = cfxPlayerScoreUI.getConfigForGroup(theGroup) - conf.id = theGroup:getID(); -- we do this ALWAYS so it is current even after a crash - - - if cfxPlayerScoreUI.simpleCommands then - -- we install directly in F-10 other - if not conf.myMainMenu then - local commandTxt = "Score / Kills" - local theCommand = missionCommands.addCommandForGroup( - conf.id, - commandTxt, - nil, - cfxPlayerScoreUI.redirectCommandX, - {conf, "score"} - ) - conf.myMainMenu = theCommand - end - - return - end - - - -- ok, first, if we don't have an F-10 menu, create one - if not (conf.myMainMenu) then - conf.myMainMenu = missionCommands.addSubMenuForGroup(conf.id, 'Score / Kills') - end - - -- clear out existing commands - cfxPlayerScoreUI.clearCommsSubmenus(conf) - - -- now we have a menu without submenus. - -- add our own submenus - cfxPlayerScoreUI.addSubMenus(conf) - -end - -function cfxPlayerScoreUI.addSubMenus(conf) - -- add menu items to choose from after - -- user clickedf on MAIN MENU. In this implementation - -- they all result invoked methods - - local commandTxt = "Show Score / Kills" - local theCommand = missionCommands.addCommandForGroup( - conf.id, - commandTxt, - conf.myMainMenu, - cfxPlayerScoreUI.redirectCommandX, - {conf, "score"} - ) - table.insert(conf.myCommands, theCommand) - -end - --- --- each menu item has a redirect and timed invoke to divorce from the --- no-debug zone in the menu invocation. Delay is .1 seconds --- +cfxPlayerScoreUI.rootCommands = {} -- by unit's group name, for player aircraft +-- redirect: avoid the debug environ of missionCommand function cfxPlayerScoreUI.redirectCommandX(args) timer.scheduleFunction(cfxPlayerScoreUI.doCommandX, args, timer.getTime() + 0.1) end function cfxPlayerScoreUI.doCommandX(args) - local conf = args[1] -- < conf in here - local what = args[2] -- < second argument in here - local theGroup = conf.theGroup - -- now fetch the first player that drives a unit in this group - -- a simpler method would be to access conf.primeUnit + local groupName = args[1] + local playerName = args[2] + local what = args[3] -- "score" or other commands + local theGroup = Group.getByName(groupName) + local gid = theGroup:getID() - local playerName, playerUnit = cfxPlayer.getFirstGroupPlayerName(theGroup) - if playerName == nil or playerUnit == nil then - trigger.action.outText("scoreUI: nil player name or unit for group " .. theGroup:getName(), 30) + if not cfxPlayerScore.scoreTextForPlayerNamed then + trigger.action.outText("***pSGui: CANNOT FIND PlayerScore MODULE", 30) return end - - local desc = cfxPlayerScore.scoreTextForPlayerNamed(playerName) - - trigger.action.outTextForGroup(conf.id, desc, 30) - trigger.action.outSoundForGroup(conf.id, "Quest Snare 3.wav") - + trigger.action.outTextForGroup(gid, desc, 30) + trigger.action.outSoundForGroup(gid, "Quest Snare 3.wav") end - -- --- G R O U P M A N A G E M E N T +-- event handling: we are only interested in birth events +-- for player aircraft -- --- Group Management is required to make sure all groups --- receive a comms menu and that they receive a clean-up --- when required --- --- Callbacks are provided by cfxPlayer module to which we --- subscribe during init --- -function cfxPlayerScoreUI.playerChangeEvent(evType, description, player, data) - - if evType == "newGroup" then - - cfxPlayerScoreUI.setCommsMenu(data.group) - - return - end +function cfxPlayerScoreUI:onEvent(event) + if event.id ~= 15 then return end -- only birth + if not event.initiator then return end -- no initiator, no joy + local theUnit = event.initiator + if not theUnit.getPlayerName then return end -- no player name, bye! + local playerName = theUnit:getPlayerName() + if not playerName then return end - if evType == "removeGroup" then - - -- we must remove the comms menu for this group else we try to add another one to this group later - local conf = cfxPlayerScoreUI.getConfigByGroupName(data.name) - - if conf then - cfxPlayerScoreUI.removeCommsFromConfig(conf) -- remove menus - cfxPlayerScoreUI.resetConfig(conf) -- re-init this group for when it re-appears - else - trigger.action.outText("+++ scoreUI: can't retrieve group <" .. data.name .. "> config: not found!", 30) + -- so now we know it's a player plane. get group name + local theGroup = theUnit:getGroup() + local groupName = theGroup:getName() + local gid = theGroup:getID() + + -- see if this group already has a score command + if cfxPlayerScoreUI.rootCommands[groupName] then + -- need re-init to store new pilot name + if cfxPlayerScoreUI.verbose then + trigger.action.outText("++pSGui: group <" .. groupName .. "> already has score menu, removing.", 30) end - - return + missionCommands.removeItemForGroup(gid, cfxPlayerScoreUI.rootCommands[groupName]) + cfxPlayerScoreUI.rootCommands[groupName] = nil end + -- we need to install a group menu item for scores. + -- will persist through death + local commandTxt = "Show Score / Kills" + local theCommand = missionCommands.addCommandForGroup( + gid, + commandTxt, + nil, -- root level + cfxPlayerScoreUI.redirectCommandX, + {groupName, playerName, "score"} + ) + cfxPlayerScoreUI.rootCommands[groupName] = theCommand + + if cfxPlayerScoreUI.verbose then + trigger.action.outText("++pSGui: installed player score menu for group <" .. groupName .. ">", 30) + end end -- -- Start -- - -function cfxPlayerScoreUI.start() - if not dcsCommon.libCheck("cfx PlayerScoreUI", - cfxPlayerScoreUI.requiredLibs) - then - return false - end - -- iterate existing groups so we have a start situation - -- now iterate through all player groups and install the Assault Troop Menu - allPlayerGroups = cfxPlayerGroups -- cfxPlayerGroups is a global, don't fuck with it! - -- contains per group player record. Does not resolve on unit level! - for gname, pgroup in pairs(allPlayerGroups) do - local theUnit = pgroup.primeUnit -- get any unit of that group - cfxPlayerScoreUI.setCommsMenuForUnit(theUnit) -- set up - end - -- now install the new group notifier to install Assault Troops menu +function cfxPlayerScoreUI.start() + -- install the event handler for new player planes + world.addEventHandler(cfxPlayerScoreUI) - cfxPlayer.addMonitor(cfxPlayerScoreUI.playerChangeEvent) trigger.action.outText("cf/x cfxPlayerScoreUI v" .. cfxPlayerScoreUI.version .. " started", 30) return true end @@ -296,7 +89,6 @@ end -- -- GO GO GO -- - if not cfxPlayerScoreUI.start() then cfxPlayerScoreUI = nil trigger.action.outText("cf/x PlayerScore UI aborted: missing libraries", 30) diff --git a/modules/cfxZones.lua b/modules/cfxZones.lua index 25602d0..b6b422d 100644 --- a/modules/cfxZones.lua +++ b/modules/cfxZones.lua @@ -1,5 +1,5 @@ cfxZones = {} -cfxZones.version = "3.0.6" +cfxZones.version = "3.0.8" -- cf/x zone management module -- reads dcs zones and makes them accessible and mutable @@ -125,6 +125,8 @@ cfxZones.version = "3.0.6" - 3.0.6 - new createSimplePolyZone() - new createSimpleQuadZone() - 3.0.7 - getPoint() can also get land y when passing true as second param +- 3.0.8 - new cfxZones.pointInOneOfZones(thePoint, zoneArray, useOrig) + --]]-- cfxZones.verbose = false cfxZones.caseSensitiveProperties = false -- set to true to make property names case sensitive @@ -1141,6 +1143,15 @@ function cfxZones.markZoneWithSmokePolarRandom(theZone, radius, smokeColor) cfxZones.markZoneWithSmokePolar(theZone, radius, degrees, smokeColor) end +function cfxZones.pointInOneOfZones(thePoint, zoneArray, useOrig) + if not zoneArray then zoneArray = cfxZones.zones end + for idx, theZone in pairs(zoneArray) do + local isIn, percent, dist = cfxZones.pointInZone(thePoint, theZone, useOrig) + if isIn then return isIn, percent, dist, theZone end + end + return false, 0, 0, nil +end + -- unitInZone returns true if theUnit is inside the zone -- the second value returned is the percentage of distance diff --git a/modules/countDown.lua b/modules/countDown.lua index 39f3da5..a0d4fd6 100644 --- a/modules/countDown.lua +++ b/modules/countDown.lua @@ -1,5 +1,5 @@ countDown = {} -countDown.version = "1.3.1" +countDown.version = "1.3.2" countDown.verbose = false countDown.ups = 1 countDown.requiredLibs = { @@ -27,7 +27,8 @@ countDown.requiredLibs = { - new reset? input - improved verbosity - zone-local verbosity - + 1.3.2 - enableCounter? to balande disableCounter? + --]]-- countDown.counters = {} @@ -141,12 +142,18 @@ function countDown.createCountDownWithZone(theZone) theZone.counterOut = cfxZones.getStringFromZoneProperty(theZone, "counterOut!", "") end - -- disableFlag + -- disableFlag/enableFlag theZone.counterDisabled = false if cfxZones.hasProperty(theZone, "disableCounter?") then theZone.disableCounterFlag = cfxZones.getStringFromZoneProperty(theZone, "disableCounter?", "") theZone.disableCounterFlagVal = cfxZones.getFlagValue(theZone.disableCounterFlag, theZone) end + + if cfxZones.hasProperty(theZone, "enableCounter?") then + theZone.enableCounterFlag = cfxZones.getStringFromZoneProperty(theZone, "enableCounter?", "") + theZone.enableCounterFlagVal = cfxZones.getFlagValue(theZone.enableCounterFlag, theZone) + end + end -- @@ -260,6 +267,12 @@ function countDown.update() aZone.counterDisabled = true end + if cfxZones.testZoneFlag(aZone, aZone.enableCounterFlag, aZone.ctdwnTriggerMethod, "enableCounterFlagVal") then + if countDown.verbose then + trigger.action.outText("+++cntD: ENabling counter " .. aZone.name, 30) + end + aZone.counterDisabled = false + end end end diff --git a/modules/dcsCommon.lua b/modules/dcsCommon.lua index 5f411fc..8176845 100644 --- a/modules/dcsCommon.lua +++ b/modules/dcsCommon.lua @@ -145,7 +145,9 @@ dcsCommon.version = "2.8.5" - new rotatePointAroundPointRad() - getClosestAirbaseTo() now supports passing list of air bases 2.8.5 - better guard in getGroupUnit() - + 2.8.6 - phonetic helpers + new spellString() + --]]-- @@ -3221,7 +3223,7 @@ function dcsCommon.LSR(a, num) end -- --- string windcards +-- string wildcards -- function dcsCommon.processStringWildcards(inMsg) -- Replace STATIC bits of message like CR and zone name @@ -3236,6 +3238,82 @@ function dcsCommon.processStringWildcards(inMsg) return outMsg end +-- +-- phonetic alphabet +-- +dcsCommon.alphabet = { + a = "alpha", + b = "bravo", + c = "charlie", + d = "delta", + e = "echo", + f = "foxtrot", + g = "golf", + h = "hotel", + i = "india", + j = "juliet", + k = "kilo", + l = "lima", + m = "mike", + n = "november", + o = "oscar", + p = "papa", + q = "quebec", + r = "romeo", + s = "sierra", + t = "tango", + u = "uniform", + v = "victor", + w = "whiskey", + x = "x-ray", + y = "yankee", + z = "zulu", +["0"] = "zero", +["1"] = "wun", +["2"] = "too", +["3"] = "tree", +["4"] = "fower", +["5"] = "fife" , +["6"] = "six", +["7"] = "seven", +["8"] = "att", +["9"] = "niner", +[" "] = "break", +} + +function dcsCommon.letter(inChar) + local theChar = "" + if type(inChar == "string") then + if #inChar < 1 then return "#ERROR0#" end + inChar = string.lower(inChar) + theChar = string.sub(inChar, 1, 1) + elseif type(inChar == "number") then + if inChar > 255 then return "#ERROR>#" end + if inChar < 0 then return "#ERROR<#" end + theChar = char(inChar) + else + return "#ERRORT#" + end +-- trigger.action.outText("doing <" .. theChar .. ">", 30) + local a = dcsCommon.alphabet[theChar] + if a == nil then a = "#ERROR?#" end + return a +end + +function dcsCommon.spellString(inString) + local res = "" + local first = true + for i = 1, #inString do + local c = inString:sub(i,i) + if first then + res = dcsCommon.letter(c) + first = false + else + res = res .. " " .. dcsCommon.letter(c) + end + end + return res +end -- -- SEMAPHORES diff --git a/modules/factoryZone.lua b/modules/factoryZone.lua new file mode 100644 index 0000000..36083c3 --- /dev/null +++ b/modules/factoryZone.lua @@ -0,0 +1,886 @@ +factoryZone = {} +factoryZone.version = "1.0.0" +factoryZone.verbose = false +factoryZone.name = "factoryZone" + +--[[-- VERSION HISTORY + +1.0.0 - refactored production part from cfxOwnedZones 1.xpcall + +--]]-- +factoryZone.requiredLibs = { + "dcsCommon", -- common is of course needed for everything + -- pretty stupid to check for this since we + -- need common to invoke the check, but anyway + "cfxZones", -- Zones, of course + "cfxCommander", -- to make troops do stuff +} + +factoryZone.zones = {} -- my factory zones +factoryZone.ups = 1 +factoryZone.initialized = false +factoryZone.defendingTime = 100 -- seconds until new defenders are produced +factoryZone.attackingTime = 300 -- seconds until new attackers are produced +factoryZone.shockTime = 200 -- 'shocked' period of inactivity +factoryZone.repairTime = 200 -- time until we raplace one lost unit, also repairs all other units to 100% + +-- persistence: all attackers we ever sent out. +-- is regularly verified and cut to size +factoryZone.spawnedAttackers = {} + +-- factoryZone is a module that managers production of units +-- inside zones and can switch production based on who owns the +-- zone. Zone ownership can by dynamic (by using OwnedZones or +-- using scripts to change the 'owner' flag +-- +-- *** EXTENTDS ZONES *** +-- +-- zone attributes when owned +-- owner: coalition that owns the zone. Managed externally +-- status: FSM for spawning +-- defendersRED/BLUE - coma separated type string for the group to spawn on defense cycle completion +-- attackersRED/BLUE - as above for attack cycle. +-- timeStamp - time when zone switched into current state +-- spawnRadius - overrides zone's radius when placing defenders. can be use to place defenders inside or outside zone itself +-- formation - defender's formation +-- attackFormation - attackers formation +-- attackRadius - radius of circle in which attackers are spawned. informs formation +-- attackDelta - polar coord: r from zone center where attackers are spawned +-- attackPhi - polar degrees where attackers are to be spawned +-- paused - will not spawn. default is false +-- unbeatable - can't be conquered by other side. default is false +-- untargetable - will not be targeted by either side. make unbeatable +-- owned zones untargetable, or they'll become a troop magnet for +-- zoneAttackers + +-- +-- M I S C +-- + + +function factoryZone.getFactoryZoneByName(zName) + for zKey, theZone in pairs (factoryZone.zones) do + if theZone.name == zName then return theZone end + end + return nil +end + +function factoryZone.addFactoryZone(aZone) + aZone.worksFor = cfxZones.getCoalitionFromZoneProperty(aZone, "factory", 0) -- currently unused, have RED/BLUE separate types + aZone.state = "init" + aZone.timeStamp = timer.getTime() + aZone.defendersRED = cfxZones.getStringFromZoneProperty(aZone, "defendersRED", "none") + aZone.defendersBLUE = cfxZones.getStringFromZoneProperty(aZone, "defendersBLUE", "none") + if cfxZones.hasProperty(aZone, "attackersRED") then + -- legacy support + aZone.attackersRED = cfxZones.getStringFromZoneProperty(aZone, "attackersRED", "none") + else + aZone.attackersRED = cfxZones.getStringFromZoneProperty(aZone, "productionRED", "none") + end + + if cfxZones.hasProperty(aZone, "attackersBLUE") then + -- legacy support + aZone.attackersBLUE = cfxZones.getStringFromZoneProperty(aZone, "attackersBLUE", "none") + else + aZone.attackersBLUE = cfxZones.getStringFromZoneProperty(aZone, "productionBLUE", "none") + end + + aZone.formation = cfxZones.getStringFromZoneProperty(aZone, "formation", "circle_out") + aZone.attackFormation = cfxZones.getStringFromZoneProperty(aZone, "attackFormation", "circle_out") -- cfxZones.getZoneProperty(aZone, "attackFormation") + aZone.spawnRadius = cfxZones.getNumberFromZoneProperty(aZone, "spawnRadius", aZone.radius-5) -- "-5" so they remaininside radius + aZone.attackRadius = cfxZones.getNumberFromZoneProperty(aZone, "attackRadius", aZone.radius) + aZone.attackDelta = cfxZones.getNumberFromZoneProperty(aZone, "attackDelta", 10) -- aZone.radius) + aZone.attackPhi = cfxZones.getNumberFromZoneProperty(aZone, "attackPhi", 0) + + aZone.paused = cfxZones.getBoolFromZoneProperty(aZone, "paused", false) + aZone.factoryOwner = aZone.owner -- copy so we can compare next round + + -- pause? and activate? + if cfxZones.hasProperty(aZone, "pause?") then + aZone.pauseFlag = cfxZones.getStringFromZoneProperty(aZone, "pause?", "none") + aZone.lastPauseValue = trigger.misc.getUserFlag(aZone.pauseFlag) + end + + if cfxZones.hasProperty(aZone, "activate?") then + aZone.activateFlag = cfxZones.getStringFromZoneProperty(aZone, "activate?", "none") + aZone.lastActivateValue = trigger.misc.getUserFlag(aZone.activateFlag) + end + + aZone.factoryTriggerMethod = cfxZones.getStringFromZoneProperty(aZone, "triggerMethod", "change") + if cfxZones.hasProperty(aZone, "factoryTriggerMethod") then + aZone.factoryTriggerMethod = cfxZones.getStringFromZoneProperty(aZone, "factoryTriggerMethod", "change") + end + + aZone.untargetable = cfxZones.getBoolFromZoneProperty(aZone, "untargetable", false) + + factoryZone.zones[aZone.name] = aZone + factoryZone.verifyZone(aZone) +end + +function factoryZone.verifyZone(aZone) + -- do some sanity checks + if not cfxGroundTroops and (aZone.attackersRED ~= "none" or aZone.attackersBLUE ~= "none") then + trigger.action.outText("+++factZ: " .. aZone.name .. " attackers need cfxGroundTroops to function", 30) + end + +end + +function factoryZone.getEnemyZonesFor(aCoalition) + -- when cfxOwnedZones is present, or it will return only those + -- else it scans all zones from cfxZones + local enemyZones = {} + local allZones = cfxZones.zones + if cfxOwnedZones then + allZones = cfxOwnedZones.zones + end + local ourEnemy = dcsCommon.getEnemyCoalitionFor(aCoalition) + for zKey, aZone in pairs(allZones) do + if aZone.owner == ourEnemy then -- only check enemy owned zones + -- note: will include untargetable zones + table.insert(enemyZones, aZone) + end + end + return enemyZones +end + +function factoryZone.getNearestOwnedZoneToPoint(aPoint) + local shortestDist = math.huge + -- when cfxOwnedZones is present, or it will return only those + -- else it scans all zones from cfxZones + local closestZone = nil + local allZones = cfxZones.zones + if cfxOwnedZones then + allZones = cfxOwnedZones.zones + end + for zKey, aZone in pairs(allZones) do + local zPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and + currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + + return closestZone, shortestDist +end + +function factoryZone.getNearestOwnedZone(theZone) + local shortestDist = math.huge + -- when cfxOwnedZones is present, or it will return only those + -- else it scans all zones from cfxZones + local closestZone = nil + local aPoint = cfxZones.getPoint(theZone) + local allZones = cfxZones.zones + if cfxOwnedZones then + allZones = cfxOwnedZones.zones + end + for zKey, aZone in pairs(allZones) do + local zPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + + return closestZone, shortestDist +end + +function factoryZone.getNearestEnemyOwnedZone(theZone, targetNeutral) + if not targetNeutral then targetNeutral = false else targetNeutral = true end + local shortestDist = math.huge + local closestZone = nil + -- when cfxOwnedZones is present, or it will return only those + -- else it scans all zones from cfxZones + local allZones = cfxZones.zones + if cfxOwnedZones then + allZones = cfxOwnedZones.zones + end + local ourEnemy = dcsCommon.getEnemyCoalitionFor(theZone.owner) + if not ourEnemy then return nil end -- we called for a neutral zone. they have no enemies + local zPoint = cfxZones.getPoint(theZone) + + for zKey, aZone in pairs(allZones) do + if targetNeutral then + -- return all zones that do not belong to us + if aZone.owner ~= theZone.owner then + local aPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(aPoint, zPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + else + -- return zones that are taken by the Enenmy + if aZone.owner == ourEnemy then -- only check own zones + local aPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + end + end + + return closestZone, shortestDist +end + +function factoryZone.getNearestFriendlyZone(theZone, targetNeutral) + if not targetNeutral then targetNeutral = false else targetNeutral = true end + local shortestDist = math.huge + local closestZone = nil + local ourEnemy = dcsCommon.getEnemyCoalitionFor(theZone.owner) + if not ourEnemy then return nil end -- we called for a neutral zone. they have no enemies nor friends, all zones would be legal. + local zPoint = cfxZones.getPoint(theZone) + -- when cfxOwnedZones is present, or it will return only those + -- else it scans all zones from cfxZones + local allZones = cfxZones.zones + if cfxOwnedZones then + allZones = cfxOwnedZones.zones + end + for zKey, aZone in pairs(allZones) do + if targetNeutral then + -- target all zones that do not belong to the enemy + if aZone.owner ~= ourEnemy then + local aPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + else + -- only target zones that are taken by us + if aZone.owner == theZone.owner then -- only check own zones + local aPoint = cfxZones.getPoint(aZone) + currDist = dcsCommon.dist(zPoint, aPoint) + if aZone.untargetable ~= true and currDist < shortestDist then + shortestDist = currDist + closestZone = aZone + end + end + end + end + + return closestZone, shortestDist +end + +function factoryZone.enemiesRemaining(aZone) + if factoryZone.getNearestEnemyOwnedZone(aZone) then return true end + return false +end + +function factoryZone.spawnAttackTroops(theTypes, aZone, aCoalition, aFormation) + local unitTypes = {} -- build type names + -- split theTypes into an array of types + unitTypes = dcsCommon.splitString(theTypes, ",") + if #unitTypes < 1 then + table.insert(unitTypes, "Soldier M4") -- make it one m4 trooper as fallback + -- simply exit, no troops specified + if factoryZone.verbose then + trigger.action.outText("+++factZ: no attackers for " .. aZone.name .. ". exiting", 30) + end + return + end + + if factoryZone.verbose then + trigger.action.outText("+++factZ: spawning attackers for " .. aZone.name, 30) + end + + local spawnPoint = {x = aZone.point.x, y = aZone.point.y, z = aZone.point.z} -- copy struct + + local rads = aZone.attackPhi * 0.01745 + spawnPoint.x = spawnPoint.x + math.cos(aZone.attackPhi) * aZone.attackDelta + spawnPoint.y = spawnPoint.y + math.sin(aZone.attackPhi) * aZone.attackDelta + + local spawnZone = cfxZones.createSimpleZone("attkSpawnZone", spawnPoint, aZone.attackRadius) + + local theGroup, theData = cfxZones.createGroundUnitsInZoneForCoalition ( + aCoalition, -- theCountry, + aZone.name .. " (A) " .. dcsCommon.numberUUID(), -- must be unique + spawnZone, + unitTypes, + aFormation, -- outward facing + 0) + return theGroup, theData +end + +function factoryZone.spawnDefensiveTroops(theTypes, aZone, aCoalition, aFormation) + local unitTypes = {} -- build type names + -- split theTypes into an array of types + unitTypes = dcsCommon.splitString(theTypes, ",") + if #unitTypes < 1 then + table.insert(unitTypes, "Soldier M4") -- make it one m4 trooper as fallback + -- simply exit, no troops specified + if factoryZone.verbose then + trigger.action.outText("+++factZ: no defenders for " .. aZone.name .. ". exiting", 30) + end + return + end + + --local theCountry = dcsCommon.coalition2county(aCoalition) + local spawnZone = cfxZones.createSimpleZone("spawnZone", aZone.point, aZone.spawnRadius) + local theGroup, theData = cfxZones.createGroundUnitsInZoneForCoalition ( + aCoalition, --theCountry, + aZone.name .. " (D) " .. dcsCommon.numberUUID(), -- must be unique + spawnZone, unitTypes, + aFormation, -- outward facing + 0) + return theGroup, theData +end + +-- +-- U P D A T E +-- + +function factoryZone.sendOutAttackers(aZone) + -- sanity check: never done for non-neutral zones + if aZone.owner == 0 then + if aZone.verbose or factoryZone.verbose then + trigger.action.outText("+++factZ: SendAttackers invoked for NEUTRAL zone <" .. aZone.name .. ">", 30) + end + return + end + + -- only spawn if there are zones to attack + if not factoryZone.enemiesRemaining(aZone) then + if factoryZone.verbose then + trigger.action.outText("+++factZ - no enemies, resting ".. aZone.name, 30) + end + return + end + + if factoryZone.verbose then + trigger.action.outText("+++factZ - attack cycle for ".. aZone.name, 30) + end + + -- step one: get the attackers + local attackers = aZone.attackersRED; + if (aZone.owner == 2) then attackers = aZone.attackersBLUE end + + if attackers == "none" then return end + + local theGroup, theData = factoryZone.spawnAttackTroops(attackers, aZone, aZone.owner, aZone.attackFormation) + + local troopData = {} + troopData.groupData = theData + troopData.orders = "attackOwnedZone" -- lazy coding! + troopData.side = aZone.owner + factoryZone.spawnedAttackers[theData.name] = troopData + + -- submit them to ground troops handler as zoneseekers + -- and our groundTroops module will handle the rest + if cfxGroundTroops then + local troops = cfxGroundTroops.createGroundTroops(theGroup) + troops.orders = "attackOwnedZone" + troops.side = aZone.owner + cfxGroundTroops.addGroundTroopsToPool(troops) -- hand off to ground troops + else + if factoryZone.verbose then + trigger.action.outText("+++ Owned Zones: no ground troops module on send out attackers", 30) + end + end +end + + +function factoryZone.repairDefenders(aZone) + -- sanity check: never done for non-neutral zones + if aZone.owner == 0 then + if aZone.verbose or factoryZone.verbose then + trigger.action.outText("+++factZ: repairDefenders invoked for NEUTRAL zone <" .. aZone.name .. ">", 30) + end + return + end + + -- find a unit that is missing from my typestring and replace it + -- one by one until we are back to full strength + -- step one: get the defenders and create a type array + local defenders = aZone.defendersRED; + if (aZone.owner == 2) then defenders = aZone.defendersBLUE end + local unitTypes = {} -- build type names + + -- if none, we are done + if defenders == "none" then return end + + -- split theTypes into an array of types + allTypes = dcsCommon.trimArray( + dcsCommon.splitString(defenders, ",") + ) + local livingTypes = {} -- init to emtpy, so we can add to it if none are alive + if (aZone.defenders) then + -- some remain. add one of the killed + livingTypes = dcsCommon.getGroupTypes(aZone.defenders) + -- we now iterate over the living types, and remove their + -- counterparts from the allTypes. We then take the first that + -- is left + + if #livingTypes > 0 then + for key, aType in pairs (livingTypes) do + if not dcsCommon.findAndRemoveFromTable(allTypes, aType) then + trigger.action.outText("+++factZ WARNING: found unmatched type <" .. aType .. "> while trying to repair defenders for ".. aZone.name, 30) + else + -- all good + end + end + end + end + + -- when we get here, allTypes is reduced to those that have been killed + if #allTypes < 1 then + trigger.action.outText("+++factZ: WARNING: all types exist when repairing defenders for ".. aZone.name, 30) + else + table.insert(livingTypes, allTypes[1]) -- we simply use the first that we find + end + -- remove the old defenders + if aZone.defenders then + aZone.defenders:destroy() + end + + -- now livingTypes holds the full array of units we need to spawn + local theCountry = dcsCommon.getACountryForCoalition(aZone.owner) + local spawnZone = cfxZones.createSimpleZone("spawnZone", aZone.point, aZone.spawnRadius) + local theGroup, theData = cfxZones.createGroundUnitsInZoneForCoalition ( + aZone.owner, -- was wrongly: theCountry + aZone.name .. dcsCommon.numberUUID(), -- must be unique + spawnZone, + livingTypes, + + aZone.formation, -- outward facing + 0) + aZone.defenders = theGroup + aZone.lastDefenders = theGroup:getSize() +end + +function factoryZone.inShock(aZone) + -- a unit was destroyed, everyone else is in shock, no rerpairs + -- group can re-shock when another unit is destroyed +end + +function factoryZone.spawnDefenders(aZone) + -- sanity check: never done for non-neutral zones + if aZone.owner == 0 then + if aZone.verbose or factoryZone.verbose then + trigger.action.outText("+++factZ: spawnDefenders invoked for NEUTRAL zone <" .. aZone.name .. ">", 30) + end + return + end + + local defenders = aZone.defendersRED; + + if (aZone.owner == 2) then defenders = aZone.defendersBLUE end + -- before we spawn new defenders, remove the old ones + if aZone.defenders then + if aZone.defenders:isExist() then + aZone.defenders:destroy() + end + aZone.defenders = nil + end + + -- if 'none', simply exit + if defenders == "none" then return end + + local theGroup, theData = factoryZone.spawnDefensiveTroops(defenders, aZone, aZone.owner, aZone.formation) + -- the troops reamin, so no orders to move, no handing off to ground troop manager + aZone.defenders = theGroup + aZone.defenderData = theData -- used for persistence + if theGroup then + aZone.lastDefenders = theGroup:getInitialSize() + else + trigger.action.outText("+++factZ: WARNING: spawned no defenders for ".. aZone.name, 30) + aZone.defenderData = nil + end +end + +-- +-- per-zone update, run down the FSM to determine what to do. +-- FSM uses timeStamp since when state was set. Possible states are +-- - init -- has just been inited for the first time. will usually immediately produce defenders, +-- and then transition to defending +-- - catured -- has just been captured. transition to defending +-- - defending -- wait until timer has reached goal, then produce defending units and transition to attacking. +-- - attacking -- wait until timer has reached goal, and then produce attacking units and send them to closest enemy zone. +-- state is interrupted as soon as a defensive unit is lost. state then goes to defending with timer starting +-- - idle - do nothing, zone's actions are turned off +-- - shocked -- a unit was destroyed. group is in shock for a time until it starts repairing. If another unit is +-- destroyed during the shocked period, the timer resets to zero and repairs are delayed +-- - repairing -- as long as we aren't at full strength, units get replaced one by one until at full strength +-- each time the timer counts down, another missing unit is replaced, and all other unit's health +-- is reset to 100% +-- +-- a Zone with the paused attribute set to true will cause it to not do anything +-- +-- check if defenders are specified +function factoryZone.usesDefenders(aZone) + if aZone.owner == 0 then return false end + local defenders = aZone.defendersRED; + if (aZone.owner == 2) then defenders = aZone.defendersBLUE end + return defenders ~= "none" +end + +function factoryZone.usesAttackers(aZone) + if aZone.owner == 0 then return false end + local attackers = aZone.attackersRED; + if (aZone.owner == 2) then defenders = aZone.attackersBLUE end + return attackers ~= "none" +end + +function factoryZone.updateZoneProduction(aZone) + -- a zone can be paused, causing it to not progress anything + -- even if zone status is still init, will NOT produce anything + -- if paused is on. + if aZone.paused then return end + + nextState = aZone.state; + + -- first, check if my defenders have been attacked and one of them has been killed + -- if so, we immediately switch to 'shocked' + if factoryZone.usesDefenders(aZone) and + aZone.defenders then + -- we have defenders + if aZone.defenders:isExist() then + -- isee if group was damaged + if not aZone.lastDefenders then + -- fresh group, probably from persistence, needs init + aZone.lastDefenders = -1 + end + if aZone.defenders:getSize() < aZone.lastDefenders then + -- yes, at least one unit destroyed + aZone.timeStamp = timer.getTime() + aZone.lastDefenders = aZone.defenders:getSize() + if aZone.lastDefenders == 0 then + aZone.defenders = nil + end + aZone.state = "shocked" + + return + else + aZone.lastDefenders = aZone.defenders:getSize() + end + + else + -- group was destroyed. erase link, and go into shock for the last time + aZone.state = "shocked" + aZone.timeStamp = timer.getTime() + aZone.lastDefenders = 0 + aZone.defenders = nil + return + end + end + + + if aZone.state == "init" then + -- during init we instantly create the defenders since + -- we assume the zone existed already + if aZone.owner > 0 then + factoryZone.spawnDefenders(aZone) + -- now drop into attacking mode to produce attackers + nextState = "attacking" + else + nextState = "idle" + end + aZone.timeStamp = timer.getTime() + + elseif aZone.state == "idle" then + -- nothing to do, zone is effectively switched off. + -- used for neutal zones or when forced to turn off + -- in some special cases + + elseif aZone.state == "captured" then + -- start the clock on defenders + nextState = "defending" + aZone.timeStamp = timer.getTime() + if factoryZone.verbose then + trigger.action.outText("+++factZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) + end + elseif aZone.state == "defending" then + if timer.getTime() > aZone.timeStamp + factoryZone.defendingTime then + factoryZone.spawnDefenders(aZone) + -- now drop into attacking mode to produce attackers + nextState = "attacking" + aZone.timeStamp = timer.getTime() + if factoryZone.verbose then + trigger.action.outText("+++factZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) + end + end + + elseif aZone.state == "repairing" then + -- we are currently rebuilding defenders unit by unit + if timer.getTime() > aZone.timeStamp + factoryZone.repairTime then + aZone.timeStamp = timer.getTime() + -- wait's up, repair one defender, then check if full strength + factoryZone.repairDefenders(aZone) + -- see if we are full strenght and if so go to attack, else set timer to reair the next unit + if aZone.defenders and aZone.defenders:isExist() and aZone.defenders:getSize() >= aZone.defenders:getInitialSize() then + -- we are at max size, time to produce some attackers + -- progress to next state + nextState = "attacking" + aZone.timeStamp = timer.getTime() + if factoryZone.verbose then + trigger.action.outText("+++factZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) + end + end + + end + + elseif aZone.state == "shocked" then + -- we are currently rebuilding defenders unit by unit + if timer.getTime() > aZone.timeStamp + factoryZone.shockTime then + nextState = "repairing" + aZone.timeStamp = timer.getTime() + if factoryZone.verbose then + trigger.action.outText("+++factZ: State " .. aZone.state .. " to " .. nextState .. " for " .. aZone.name, 30) + end + end + + elseif aZone.state == "attacking" then + if timer.getTime() > aZone.timeStamp + factoryZone.attackingTime then + factoryZone.sendOutAttackers(aZone) + -- reset timer + aZone.timeStamp = timer.getTime() + if factoryZone.verbose then + trigger.action.outText("+++factZ: State " .. aZone.state .. " reset for " .. aZone.name, 30) + end + end + else + -- unknown zone state + end + aZone.state = nextState +end + +function factoryZone.GC() + -- GC run. remove all my dead remembered troops + local before = #factoryZone.spawnedAttackers + local filteredAttackers = {} + for gName, gData in pairs (factoryZone.spawnedAttackers) do + -- all we need to do is get the group of that name + -- and if it still returns units we are fine + local gameGroup = Group.getByName(gName) + if gameGroup and gameGroup:isExist() and gameGroup:getSize() > 0 then + filteredAttackers[gName] = gData + end + end + factoryZone.spawnedAttackers = filteredAttackers + if factoryZone.verbose then + trigger.action.outText("owned zones GC ran: before <" .. before .. ">, after <" .. #factoryZone.spawnedAttackers .. ">", 30) + end +end + +function factoryZone.update() + factoryZone.updateSchedule = timer.scheduleFunction(factoryZone.update, {}, timer.getTime() + 1/factoryZone.ups) + -- iterate all zones to see if ownership has + -- changed + + for idz, theZone in pairs(factoryZone.zones) do + local lastOwner = theZone.factoryOwner + local newOwner = theZone.owner + if (newOwner ~= lastOwner) then + theZone.state = "captured" + theZone.timeStamp = timer.getTime() + theZone.factoryOwner = theZone.owner + if theZone.verbose or factoryZone.verbose then + trigger.action.outText("+++factZ: detected factory <" .. theZone.name .. "> changed ownership from <" .. lastOwner .. "> to <" .. theZone.owner .. ">", 30) + end + end + + -- see if pause/unpause was issued + if theZone.pauseFlag and cfxZones.testZoneFlag(theZone, theZone.pauseFlag, theZone.factoryTriggerMethod, "lastPauseValue") then + theZone.paused = true + end + + if theZone.activateFlag and cfxZones.testZoneFlag(theZone, theZone.activateFlag, theZone.factoryTriggerMethod, "lastActivateValue") then + theZone.paused = false + end + -- do production for this zone + factoryZone.updateZoneProduction(theZone) + end -- iterating all zones +end + +function factoryZone.houseKeeping() + timer.scheduleFunction(factoryZone.houseKeeping, {}, timer.getTime() + 5 * 60) -- every 5 minutes + factoryZone.GC() +end + + +-- +-- load / save data +-- + +function factoryZone.saveData() + -- this is called from persistence when it's time to + -- save data. returns a table with all my data + local theData = {} + local allZoneData = {} + -- iterate all my zones and create data + for idx, theZone in pairs(factoryZone.zones) do + local zoneData = {} + if theZone.defenderData then + zoneData.defenderData = dcsCommon.clone(theZone.defenderData) + dcsCommon.synchGroupData(zoneData.defenderData) + end + zoneData.owner = theZone.owner + zoneData.state = theZone.state -- will prevent immediate spawn + -- since new zones are spawned with 'init' + allZoneData[theZone.name] = zoneData + end + + -- now iterate all attack groups that we have spawned and that + -- (maybe) are still alive + factoryZone.GC() -- start with a GC run to remove all dead + local livingAttackers = {} + for gName, gData in pairs (factoryZone.spawnedAttackers) do + -- all we need to do is get the group of that name + -- and if it still returns units we are fine + -- spawnedAttackers is a [groupName] table with {.groupData, .orders, .side} + local gameGroup = Group.getByName(gName) + if gameGroup and gameGroup:isExist() then + if gameGroup:getSize() > 0 then + local sData = dcsCommon.clone(gData) + dcsCommon.synchGroupData(sData.groupData) + livingAttackers[gName] = sData + end + end + end + + -- now write the info for the flags that we output for #red, etc + local flagInfo = {} -- no longer used + + -- assemble the data + theData.zoneData = allZoneData + theData.attackers = livingAttackers + theData.flagInfo = flagInfo + + -- return it + return theData +end + +function factoryZone.loadData() + -- remember to draw in map with new owner + if not persistence then return end + local theData = persistence.getSavedDataForModule("factoryZone") + if not theData then + if factoryZone.verbose then + trigger.action.outText("factZ: no save date received, skipping.", 30) + end + return + end + -- theData contains the following tables: + -- zoneData: per-zone data + -- flagInfo: module-global flags + -- attackers: all spawned attackers that we feed to groundTroops + local allZoneData = theData.zoneData + for zName, zData in pairs(allZoneData) do + -- access zone + local theZone = factoryZone.getOwnedZoneByName(zName) + if theZone then + if zData.defenderData then + if theZone.defenders and theZone.defenders:isExist() then + -- should not happen, but so be it + theZone.defenders:destroy() + end + local gData = zData.defenderData + local cty = gData.cty + local cat = gData.cat + theZone.defenders = coalition.addGroup(cty, cat, gData) + theZone.defenderData = zData.defenderData + end + theZone.owner = zData.owner + theZone.factoryOwner = theZone.owner + theZone.state = zData.state + + else + trigger.action.outText("factZ: load - data mismatch: cannot find zone <" .. zName .. ">, skipping zone.", 30) + end + end + + -- now process all attackers + local allAttackers = theData.attackers + for gName, gdTroop in pairs(allAttackers) do + -- table is {.groupData, .orders, .side} + local gData = gdTroop.groupData + local orders = gdTroop.orders + local side = gdTroop.side + local cty = gData.cty + local cat = gData.cat + -- add to my own attacker queue so we can save later + local dClone = dcsCommon.clone(gdTroop) + factoryZone.spawnedAttackers[gName] = dClone + local theGroup = coalition.addGroup(cty, cat, gData) + if cfxGroundTroops then + local troops = cfxGroundTroops.createGroundTroops(theGroup) + troops.orders = orders + troops.side = side + cfxGroundTroops.addGroundTroopsToPool(troops) -- hand off to ground troops + end + end + + -- now process module global flags + local flagInfo = theData.flagInfo + if flagInfo then + end +end + + +-- +function factoryZone.readConfigZone(theZone) + if not theZone then theZone = cfxZones.createSimpleZone("factoryZoneConfig") end + factoryZone.name = "factoryZone" -- just in case, so we can access with cfxZones + factoryZone.verbose = cfxZones.getBoolFromZoneProperty(theZone, "verbose", false) + factoryZone.defendingTime = cfxZones.getNumberFromZoneProperty(theZone, "defendingTime", 100) + factoryZone.attackingTime = cfxZones.getNumberFromZoneProperty(theZone, "attackingTime", 300) + factoryZone.shockTime = cfxZones.getNumberFromZoneProperty(theZone, "shockTime", 200) + factoryZone.repairTime = cfxZones.getNumberFromZoneProperty(theZone, "repairTime", 200) +end + +function factoryZone.init() + -- check libs + if not dcsCommon.libCheck("cfx Owned Zones", + factoryZone.requiredLibs) then + return false + end + + -- read my config zone + local theZone = cfxZones.getZoneByName("factoryZoneConfig") + factoryZone.readConfigZone(theZone) + + -- collect all owned zones by their 'factory' property + -- start the process + local pZones = cfxZones.zonesWithProperty("factory") + + -- now add all zones to my zones table, and convert the owner property into + -- a proper attribute + for k, aZone in pairs(pZones) do + factoryZone.addFactoryZone(aZone) + end + + if persistence then + -- sign up for persistence + callbacks = {} + callbacks.persistData = factoryZone.saveData + persistence.registerModule("factoryZone", callbacks) + -- now load my data + factoryZone.loadData() + end + + initialized = true + factoryZone.updateSchedule = timer.scheduleFunction(factoryZone.update, {}, timer.getTime() + 1/factoryZone.ups) + + -- start housekeeping + factoryZone.houseKeeping() + + trigger.action.outText("cx/x factory zones v".. factoryZone.version .. " started", 30) + + return true +end + +if not factoryZone.init() then + trigger.action.outText("cf/x Factory Zones aborted: missing libraries", 30) + factoryZone = nil +end + + +-- add property to factory attribute to restrict production to that side, +-- eg factory blue will only work for blue, else will work for any side +-- currently not needed since we have defendersRED/BLUE and productionRED/BLUE diff --git a/modules/persistence.lua b/modules/persistence.lua index 5284a80..a9fae97 100644 --- a/modules/persistence.lua +++ b/modules/persistence.lua @@ -1,5 +1,5 @@ persistence = {} -persistence.version = "1.0.6" +persistence.version = "1.0.7" persistence.ups = 1 -- once every 1 seconds persistence.verbose = false persistence.active = false @@ -26,7 +26,8 @@ persistence.requiredLibs = { new 'saveNotification" can be off 1.0.4 - new optional 'root' property 1.0.5 - desanitize check on readConfig to early-abort - 1.0.6 - removed potential verbosity bug + 1.0.6 - removed potential verbosity bug + 1.0.7 - correct abort for sanitized DCS, when non-verbose PROVIDES LOAD/SAVE ABILITY TO MODULES @@ -513,24 +514,24 @@ function persistence.start() if not dcsCommon.libCheck("persistence", persistence.requiredLibs) then return false end - + -- read config persistence.saveFileName = dcsCommon.getMissionName() .. " Data.txt" persistence.readConfigZone() -- let's see it lfs and io are online persistence.active = false - if not _G["lfs"] then + if (not _G["lfs"]) or (not lfs) then if persistence.verbose then trigger.action.outText("+++persistence requires 'lfs'", 30) - return false end + return false end if not _G["io"] then if persistence.verbose then trigger.action.outText("+++persistence requires 'io'", 30) - return false end + return false end -- local mainDir = lfs.writedir() .. persistence.serverDir diff --git a/tutorial & demo missions/demo - Later Score.miz b/tutorial & demo missions/demo - Later Score.miz new file mode 100644 index 0000000..a2ca3ec Binary files /dev/null and b/tutorial & demo missions/demo - Later Score.miz differ diff --git a/tutorial & demo missions/demo - more score.miz b/tutorial & demo missions/demo - more score.miz index d71560a..5c32691 100644 Binary files a/tutorial & demo missions/demo - more score.miz and b/tutorial & demo missions/demo - more score.miz differ diff --git a/tutorial & demo missions/demo - player score.miz b/tutorial & demo missions/demo - player score.miz index 3753691..4125ed5 100644 Binary files a/tutorial & demo missions/demo - player score.miz and b/tutorial & demo missions/demo - player score.miz differ