-
Notifications
You must be signed in to change notification settings - Fork 18
AI System
The AI interface is long overdue for a rewrite. There were too many changes that made porting a lot harder. So, let's figure this out.
First we have to make sure the original HSP interface is well understood. Here are the relevant parts:
Purpose: This is a property on characters that determines the attitude of the character towards the player. If they hate the player, the allies hate them by proxy.
if cRelation(cc)=cAlly{
if cRelation(cellChara)=cEnemy:cTarget(cc)=cellChara:else:blockedByChara=true
}
This can have one of the following values:
#define global cAlly 10
#define global cNeutral 0
#define global cDislike -1
#define global cHate -2
#define global cEnemy -3
- cAlly: They are in the player's party, temporary or not. This means their index should be < 16.
- cNeutral: You normally displace them when walking into them. They can still get healed by AoE magic and similar.
- cDislike: You melee them when walking into them. They won't actively seek you out as a target, though.
- cHate: I think this is caused by bashing someone who is neutral. The next bash causes them to turn into
cEnemy
. - cEnemy: They will actively search for and target you, and allies will do the same vice versa.
Port: This will probably be the hardest thing of all to get right.
I was originally intending to future-proof this part, but the future-proofing is exactly what got us into the situation of having to redesign the AI. cRelation
only accounts for the character's affiliation relative to the player. What I wanted was to leave open the possibility of characters having different reactions between each other, for example between different opposing factions. This would mean that even if two characters are on the same "side" (enemy and enemy) they could still aggro against each other if their reaction to one another was negative. Since this is hard to retrofit on I wanted it designed in from the start.
Elona was not designed for this originally, and there's nothing in the original codebase that would benefit from taking advantage of such a feature. cRelation
is just a flag that indicates if this character is an enemy or ally, nothing more. Also, it would be useless to have the player have a high reaction towards a character that has an enemy reaction towards the player. Two characters of cEnemy will always ignore each other, two of cAlly will always ignore each other.
if cc:relation_towards(player) >= Enum.Relation.Ally then
if cellChara:relation_towards(player) == Enum.Relation.Enemy then
cc:set_target(cellChara)
else
blockedByChara = true
end
end
Purpose: Original relation of the character, for ignoring temporary things like anger from bashing. This allows you to reset the aggro of hostile guards with Incognito.
if cRelation(cnt)<=cHate:cAiAggro(cnt)=0 : cRelation(cnt)=cOrgRelation(cnt):cEmoIcon(cnt)=emoQuestion+extEmo*2
Port:
if cnt:relation_towards(player) <= Enum.Relation.Hate then
cnt.ai_aggro = 0
cnt:set_relation_towards(player, cnt:original_relation_towards(player))
end
Purpose: Property on a character indicating, well, aggro. It ticks down each AI action, and if it reaches 0 the AI will reset its target.
flt :chara_create -1,p,x,y :cAiAggro(rc)=1000:cTarget(rc)=pc
Purpose: Target character index.
cTarget(cc)=fireGiantId:cAiAggro(cc)=500
Purpose: Item the AI is about to use. Set when trading consumables. They will use the appropriate action based on the item's category on the next turn, if nothing else is happening.
if (a=fltFood)or(a=fltPotion)or(a=fltScroll) :cAiItem(rc)=ci
a=iType(ci)
if cRelation(cc)!cNeutral:cAiItem(cc)=0
if a=fltFood:if (cRelation(cc)=cAlly)&(cHunger(cc)>defAllyHunger):else{
goto *act_eat
}
if a=fltPotion : goto *act_drink
if a=fltScroll : goto *act_read
Purpose: ID of a spell or action the character will use when their health is below 25%.
efId=cActHeal(cc)
if (efId>=headSpell)&&(efId<tailSpell){
call cast,(npcCostMp=true):if stat=true : goto *turn_end
}else{
if efId>=headSpAct:call action:if stat=true:goto *turn_end
}
Purpose: If the AI is not exactly this distance from the target, they will try moving closer 1 / cAiMoveFreq(cc)
times.
if distance!cAiDistance(cc){
if rnd(100)<cAiMoveFreq(cc):goto *ai_followMove
}
Purpose: The meat of the AI. These are what determines the usual action the AI could take. cActIndex
is composed as (subActCount * 10) + mainActCount
. The two lists of the appropriate length hold the possible action IDs.
If the ID is a spell or action ID, the AI will use it (dependent on MP for spells).
act=cdata(headActMain+rnd(cActIndex(cc)¥10),cc)
cAiSub(rc) = 20
cdata(headActMain,rc) = spMagicArrow,actMelee,spChaosBall
cdata(headActSub,rc) = spSummon
cActIndex(rc) = 13
Possible main actions.
#define global actMelee -1
#define global actRange -2
#define global actWaitMelee -3
#define global actRandomMove -4
- actMelee - Falls through. Melee is the default action regardless.
- actRange - Preemptively tries to fire before doing anything else. They will still check at the end if nothing else matches.
- actWaitMelee - Skips the logic for moving closer, but tries ranged or melee attacks instead. Used by ranged units and such.
- actRandomMove - Picks a random surrounding tile and moves there if not blocked.
In addition, there's some hardcoded logic for getting a ranged weapon out or moving towards the target if everything else falls through.
Purpose: Chance the AI will take its sub action, if it's nonzero.
if cAiSub(cc)!0 : if cAiSub(cc)>rnd(100) {
; ...
}
Possible sub actions. These are all dependent on the NPC throw distance, which is hardcoded at defNpcFovThrow
(8).
#enum global headActThrow =-10000
#enum global actThrowPotionMinor
#enum global actThrowPotionMajor
#enum global actThrowPotionGreater
#enum global actThrowSalt
#enum global tailActThrow
- actThrowPotionMinor - Creates a potion from a specified set, then throws it.
- actThrowPotionMajor - Same as above, different potions.
- actThrowPotionGreater - Same as above, different potions.
- actThrowSalt - Creates salt instead.
Purpose: A flag that causes the AI to cheat and gain back some MP if they're running low when trying to cast a spell from cActIndex(cc)
. It seems to only be used by the mine dog.
if cMP(cc)<cMMP(cc)/7 : if (rnd(3))or(cc<maxFollower)or(cQuality(cc)>=fixGreat)or(cBit(cAiSaveMana,cc)):cMP(cc)+=cLevel(cc)/4+5:goto *turn_end
Purpose: Determines what happens when the AI has nothing better to do.
Possible options:
#enum global aiNull=0
#enum global aiRoam
#enum global aiDull
#enum global aiStand
#enum global aiFollow
#enum global aiSpecial
- aiNull - Unused.
- aiRoam - Picks a random surrounding tile and moves there if not blocked.
- aiDull - Same as
aiRoam
, except if the character has a designated position, they will generally try to stay near that point. Used so shopkeepers don't go wandering off. - aiStand - Does nothing.
- aiFollow - Skips a lot of logic like getting drunk or using items and instead follows the player around. Used by <Gwen> the innocent.
- aiSpecial - If the character matches an ID, does some special logic, otherwise the same as
aiRoam
.
Index of the last character that attacked the player. Used for when an ally doesn't have a target, but the player has been attacked. This causes the party member to set their target to this character.
if pcAttacker!0 : if cRelation(pcAttacker)<=cEnemy :if cExist(pcAttacker)=cAlive :if fov_los(cX(cc),cY(cc),cX(pcAttacker),cY(pcAttacker)):cAiAggro(cc)=5:cTarget(cc)=pcAttacker
Some characters run away.
if (retreat)or(cAiDistance(cc)>distance){
cXdest(cc)=cX(cc)+(cX(cc)-cX(tc))
cYdest(cc)=cY(cc)+(cY(cc)-cY(tc))
}
- Pet arena logic gets inserted for changing targets and such.
Of course, we should take into consideration things like Elona+'s evolution system and Custom's user-editable AI.
When a pet is evolved, it gains some extra actions depending on the evolution. These get inserted dynamically.
One of the revolutionary features that keeps everyone on Custom.
Basically you have targets, conditions, comparators and actions. Each one gets run in sequence. If self HP < 25%, then move away. If target status != sleep, then Touch of Sleep.
You can set a chance to proc for each tactic. There's also a thing called "Toggle Preserve Entity As Target". I believe what it means is if a tactic targeting an enemy gets run, then you can ensure that the targeted character will also be targeted again when the next turn happens. From playing Disgaea 6, I remember they also had a targeting predicate like that for their custom AI system.
Building the list of available actions takes work, since you have to make sure to dynamically insert the evolution actions. You also get access to the standard potion-throwing sub actions too.
Canonically, this part runs right before *ai_actCalm
/*ai_actMain
in the code.
To support this we're definitely going to need an interface for querying the available magic and actions a unit has, and formatting them with a unified "AI action" definition. Even if the level and potential get ignored in favor of compatibility, rebuilding the list every time based on conditions like evolution doesn't sound maintainable. The action list should also include the special AI actions.
Speaking of Disgaea 6, the Mashin Edit system was pretty neat. The 2D logic grid and ease-of-use was really reminiscent of Scratch, to me. Perhaps it will have minted a handful of new SWEs in a decade. Too bad it breaks half the time when you try to do something complicated, multi-unit lockstep is tedious to get right, and the UX was suboptimal (especially for editing conditional branches). I wonder what could be possible if you could put in a more robust version that's also extensible with externally defined predicates. Or maybe just do something like GOAP instead.
Zeome gets his own AI routine that replaces *ai_actMain
. What this implies is that we'd need a way of inserting a check for Zeome in the AI at the correct place.
I'm wondering what the best way of doing this is. Right now you specify an AI identifier and it gets run. But do you want to make a near-exact copy of the default AI just so you can insert your custom_zeome
bits? It sounds brittle, considering other people might want to add their own AI parts in the default AI that affect all other AI types also.
Allows different factions to have set reactions towards one another.
There's a new function called factionbetween
that seems to serve a similar purpose to relationbetween
, and gets called during AI target searching only.
if (relationbetween(cc, tc) == (-3) | factionbetween(cc, tc) == (-3)) {
; ...
}
The actual function itself is absurdly complicated and dependent on things like the current map. It looks like we'd need some kind of hook in reaction_towards()
.
- Party members will follow their leader if they have nothing else to do.
- The player and allies are part of their own party.
- It is possible for the AI to displace characters based on party and relation.
It's possible for the AI to have more than one target at a time, and the priority for each are ordered by distance. This system completely replaces cTarget(cc)
.
There are two sets: "targets" (ocdata(155, cc)
) and "watches" (ocdata(161)
), each with a maximum of 5 slots. Targets are for enemies, watches are for allies. The AI will first see if there is a watch active, and do healing/buffing on it instead of the usual AI action. Otherwise it will substitute in the target with the nearest distance.
Watches are set along with targets during the AI targeting routine. The criteria involves checking if self has an unhexing spell and an ally is hexed, and so on.
if (ocdata(161, cc)) {
tc = ocdata(156, cc) - 1
goto *label_1837
}
if (ocdata(155, cc)) {
tc = ocdata(150, cc) - 1
}
Of course there is also logic to make sure dead/missing targets and watches are pruned, etc.
So here's the annoying part.
First of all, for going towards something looking like compatibility, we have to separate known spells/actions from the AI that uses them. Let's take Zeome as an example.
cAiSub(rc) = 20
cdata(headActMain,rc) = spMagicArrow,actMelee,spChaosBall
cdata(headActSub,rc) = spSummon
cActIndex(rc) = 13
Here we have a 20% chance to use Summon, otherwise try one of the other actions. I think at this point it's clear that having the learned skills be the same between PC and others is best for consistency (the above is just hardcoded for the purpose of the AI). So maybe something like this.
data:add {
_type = "base.chara",
_id = "zeome",
skills = {
"elona.spell_magic_arrow",
"elona.spell_chaos_ball",
"elona.spell_summon_monsters",
},
ai = {
_id = "base.default",
main_actions = {
{ "elona_sys.spell", "elona.spell_magic_arrow" },
{ "elona_sys.spell", "elona.spell_chaos_ball" },
{ "base.ai_action", "base.melee" },
},
sub_actions = {
{ "elona_sys.spell", "elona.spell_chaos_ball" },
},
sub_action_chance = 20,
calm_action = "roam",
move_chance = 100,
move_target_distance = 1
}
}
So we're using the base.skill
entries here, not the elona_sys.magic
ones. We're assuming these are going to have tracked skill level and potential. We might not use them, but that's how they're going to be serialized.
In ai
we pass a custom AI config to be interpreted by base.default
somehow. The one provided here contains the bare minimum information needed to have compatibility with vanilla.
So the assumption is that you can just do away with all that and put in your own AI with its own parameters and such. The issue is that requires a lot of retrofitting. You'd be overwriting the ai
table for the new AI you want to add. Maybe just rename this to default_ai
or something and do away with _id
so it's always there.
Yet we'd have to be careful as to where these constants get used. Are they actually immutable constants, or will we be able to modify them at runtime? Probably not that useful. Will they be used in places outside the default AI implementation code? Then maybe they shouldn't be in ai
but exposed as a property on base.chara
instead.
Next issue that immediately comes to mind is: if you want to extend the default AI without replacing the entire thing, what needs to be done? The first thing I can think of is having "cut points" of some kind. Technically we do this already. Have a table hosting a lot of actions, insert the one you want at the appropriate place.
local idle_action = {
"elona.follow_player",
function()
if not Rand.one_in(5) then return true end
end,
"elona.on_ai_calm_actions",
"elona.go_to_preset_anchor",
"elona.wander",
}
Ai.insert_after(idle_action, "elona.follow_player", custom.zeome")
It's a bit brittle if you need it to show up at a particular spot, though. We already have an event system; we could reuse the same paradigm. Attach priorities to these actions, register a new one with a priority that gets sorted between two of them. The issue with this is that now we've traded away a lot of maintainability. This isn't just a collection of functions; now they will jump around a lot in an opaque manner based on how the event system works. It's hard to get a hold of how it works just by reading a list of numbers and the functions attached to them. And it's very important that two functions that happen to have the same priority get run in a predictable order for the AI, which I don't see as practical here. If it's just a list then one will always be ordered after another.
For all practical purposes, there are only a couple of places in the default AI that are important to modify. Since we can swap out the entire AI anyway, it's probably not a big deal if we just go with a straight up port without worrying about extensibility. The important places we can put event hooks into later. That's where the Custom AI would be implemented.
I think I can articulate why the new faction/relationship system was so hard to port.
It changed the semantics from querying "is this character an ally or enemy" to "are these two characters allies or enemies between each other." This is a breaking change if I ever saw one.
If we can eat the complexity of having to ensure the correctness of everything, then it would open some additional possibilities. But it will still be tricky.
Another issue, which might have been more difficult to work around, was the fact that there were no defined constants like cAlly
that made it easy to see at a glance what the checks were. That and subtraction of these "attitude" constants by arbitrary amounts that were not well-defined. If those could get codified, then half the problem is already solved.
Here is what the faction definitions look like right now.
data:add {
_type = "base.faction",
_id = "friendly",
reactions = {
["base.citizen"] = 100,
["base.neutral"] = 50,
["base.enemy"] = -100,
}
},
What are the problems with this?
- It isn't clear what an arbitrary constant like "100" means. Is it the best possible?
- Every time you add a new faction, you'll have to insert a new entry into every other faction's
reactions
table. -
base.friendly
conflicts with something likeorctopia.race_of_orc
. You might want the orc to be friendly to you by default, but also part of the "orc" faction, which would still end up hating you if you're a dwarf or something. You can't do that with the current system.
I think what we want is two separate layers. You have a defined "default" relation, friend/enemy, and factions are a constant modifier to those. Factions wouldn't even need to be a part of the base game.
Parties would be another layer on top of those two features, for things like leader attacking causing aggro and following the leader.
So how should characters react towards one another, given an affiliation?
Player | Ally | Citizen | Neutral | Enemy | |
---|---|---|---|---|---|
Player | + | + | + | + | x |
Ally | + | + | + | + | x |
Citizen | + | + | + | + | x |
Neutral | + | + | + | + | x |
Enemy | x | x | x | x | + |
The only important case is enemy/non-enemy. But it's also important that enemies see each other as allies, or at least ignore each other.
I think we will go with :get_target()
and :set_target()
for these. Same with having one value for aggro, for now. But we will set the target and aggro in one go, in case it's desirable to associate one with the other. So :set_target(cc, aggro)
and :get_aggro(cc)
. It's not really common for one to not be set along with the other in the original, and doing it this way means we can potentially associate the two.
Create an interface that holds the implementations of :get_targets()
, :get_primary_target()
, :relation_towards()
, etc. Make each AI system implement that. Instantiate it and forward it using the main interface on IChara
.
if cRelation(cc)>=cAlly{
cAiAggro(cc)--
if (cTarget(cc)=0)or(cAiAggro(cc)<=0)or((cRelation(cTarget(cc))>=cHate)&(cTarget(cTarget(cc))!cc)){
cTarget(cc)=0
if pcAttacker!0 : if cRelation(pcAttacker)<=cEnemy :if cExist(pcAttacker)=cAlive :if fov_los(cX(cc),cY(cc),cX(pcAttacker),cY(pcAttacker)):cAiAggro(cc)=5:cTarget(cc)=pcAttacker
if cTarget(cc)=0 : if (cTarget(pc)!0)&(cRelation(cTarget(pc))<=cEnemy) :if cExist(cTarget(pc))=cAlive :if fov_los(cX(cc),cY(cc),cX(cTarget(pc)),cY(cTarget(pc))):cAiAggro(cc)=5:cTarget(cc)=cTarget(pc)
}
if cBit(cInvisi,cTarget(cc))=true:if cBit(cSeeInvisi,cc)=false:if cWet(cTarget(cc))=0:if rnd(5):cTarget(cc)=pc
}
if cTarget(cc)!0 : if cExist(cTarget(cc))!cAlive : cTarget(cc)=0 :cAiAggro(cc)=0
if cc:is_ally_of_player() then
for _, target in cc:iter_targets() do
cc:mod_aggro(target, -1)
end
local target = cc:get_primary_target()
if target == nil or cc:get_aggro(target) <= 0 or (cc:reaction_towards(target) >= Ai.Reaction.Hate and target:get_target() ~= cc) then
cc:set_target(Chara.player())
local leader_attacker = cc:get_party().metadata.leader_attacker
if leader_attacker
and cc:reaction_towards(leader_attacker) <= Ai.Reaction.Enemy
and Chara.is_alive(leader_attacker)
and cc:has_los(leader_attacker)
then
cc:set_target(leader_attacker, 5)
end
local leader_target = cc:get_party_leader():get_primary_target()
if leader_target
and cc:reaction_towards(leader_target) <= Ai.Reaction.Enemy
and Chara.is_alive(leader_target)
and cc:has_los(leader_target)
then
cc:set_target(leader_target, 5)
end
end
end
local target = cc:get_primary_target()
if not target:is_player() and not Chara.is_alive(target) then
cc:set_target(Chara.player(), 0)
end
If we had a visual AI system, I'd have the following niceties:
- Custom modules.
- Nested modules, like Pure Data's subpatches, to save useful abstractions and deploy them in a compact form.
- "Jump and continue" special operator, to prevent duplication of identical branch paths.
- "Stop if failed, otherwise continue" modules. This is for things like "get closer if not close enough to do something, otherwise do it."
- Modules with the ability to stop, continue, or branch all in the same code.