Add the Plan Unit Advance modification to mainline.

This commit is contained in:
Pentarctagon 2021-01-30 01:42:03 -06:00 committed by Pentarctagon
parent c05e39ecc1
commit 093db78cc7
8 changed files with 555 additions and 0 deletions

View File

@ -8,6 +8,7 @@
wesnoth.dofile 'lua/wml-tags.lua'
wesnoth.dofile 'lua/feeding.lua'
wesnoth.dofile 'lua/diversion.lua'
wesnoth.dofile 'lua/as_text.lua'
>>
[/lua]

62
data/lua/as_text.lua Normal file
View File

@ -0,0 +1,62 @@
-- NOTE: the string output from here is intended solely as an aid in debugging; it should never be taken and used as an input to anything else.
-- escaping takes 3/4 of the time, but we can't avoid it...
local function escape(str)
str = string.gsub(str, "%c", "")
str = string.gsub(str, "[\\\"]", "\\%0")
return str
end
local function add_table_key(obj, buffer)
local _type = type(obj)
if _type == "string" then
buffer[#buffer + 1] = escape(obj)
elseif _type == "number" then
buffer[#buffer + 1] = obj
elseif _type == "boolean" then
buffer[#buffer + 1] = tostring(obj)
else
buffer[#buffer + 1] = '???' .. _type .. '???'
end
end
local function format_any_value(obj, buffer)
local _type = type(obj)
if _type == "table" then
buffer[#buffer + 1] = '{'
buffer[#buffer + 1] = '"' -- needs to be separate for empty tables {}
for key, value in pairs(obj) do
add_table_key(key, buffer)
buffer[#buffer + 1] = '":'
format_any_value(value, buffer)
buffer[#buffer + 1] = ',"'
end
buffer[#buffer] = '}' -- note the overwrite
elseif _type == "string" then
buffer[#buffer + 1] = '"' .. escape(obj) .. '"'
elseif _type == "boolean" or _type == "number" then
buffer[#buffer + 1] = tostring(obj)
elseif _type == "userdata" then
buffer[#buffer + 1] = '"' .. escape(tostring(obj)) .. '"'
else
buffer[#buffer + 1] = '"???' .. _type .. '???"'
end
end
local function value_to_text(obj)
if obj == nil then return "null" else
local buffer = {}
format_any_value(obj, buffer)
return table.concat(buffer)
end
end
function wesnoth.as_text(...)
local result = {}
local n = 1
for _, v in ipairs({ ... }) do
result[n] = value_to_text(v)
n = n + 1
end
return table.concat(result, "\t")
end

View File

@ -0,0 +1,32 @@
#textdomain wesnoth
#ifdef MULTIPLAYER
[modification]
id=plan_unit_advance
name=_"Plan Unit Advance"
description=_"When playing a multiplayer game, you do not control what a unit advances if they advances on another player's turn. With this modification you can set what your units advance to beforehand either for a specific unit or for all units of the same type."
[options]
[checkbox]
id="pickadvance_force_choice" # WARNING: do not change this ID because other maps are relying on it
name=_"Force advancement planning"
description=_"You will be asked a question on choosing advancement whenever an undecided unit appears.
Some eras and scenarios may automatically enable this option."
default=no
[/checkbox]
[/options]
[event]
name=preload
first_time_only=no
[lua]
code= {./modifications/pick_advance/dialog.lua}
[/lua]
[lua]
code= {./modifications/pick_advance/main.lua}
[/lua]
[/event]
[/modification]
#endif

View File

@ -0,0 +1,12 @@
## How it works
dialog "set" actions:
* client (local only)
* unit.advances_to
* wml.variables
recruit, post advance events:
* get from wml.variables, set
* get from local client, publicly set

View File

@ -0,0 +1,129 @@
-- << pickadvance_dialog
pickadvance = {}
local T = wesnoth.require("lua/helper.lua").set_wml_tag_metatable {}
local _ = wesnoth.textdomain "wesnoth"
local function filter_false(arr)
local result = {}
for _, v in ipairs(arr) do
if v ~= false then
result[#result + 1] = v
end
end
return result
end
function pickadvance.show_dialog_unsynchronized(advance_info, unit)
-- dialog exit codes --
local reset_code = -3
local single_unit_code = -1
local all_units_code = 1
--
local unit_type_options = advance_info.type_advances
local options = {}
for _, ut in ipairs(unit_type_options) do
options[#options + 1] = wesnoth.unit_types[ut]
end
local unit_override_one = (advance_info.unit_override or {})[2] == nil
and (advance_info.unit_override or {})[1] or nil
local game_override_one = (advance_info.game_override or {})[2] == nil
and (advance_info.game_override or {})[1] or nil
local description_row = T.row {
T.column { T.label { use_markup = true, label = _"Plan advance:" } },
}
local list_sub_row = T.row {
T.column { T.image { id = "the_icon" } },
T.column { grow_factor = 0, T.label { use_markup = true, id = "the_label" } },
T.column { grow_factor = 1, T.spacer {} },
}
local toggle_panel = T.toggle_panel { return_value = single_unit_code, T.grid { list_sub_row } }
local list_definition = T.list_definition { T.row { T.column { horizontal_grow = true, toggle_panel } } }
local listbox = T.listbox { id = "the_list", list_definition, has_minimum = true }
local reset_button = T.button {
return_value = reset_code,
label = _"Reset",
tooltip = _"Reset advancements to default"
}
local unit_button = T.button {
return_value = single_unit_code,
label = _"Save",
tooltip = _"Save the advancement for this unit only"
}
local recruits_subbutton = T.button {
return_value = all_units_code,
label = _"Save (all)",
tooltip = _"Save the advancement for all units of this type"
}
local recruits_button = not unit.canrecruit and T.row { T.column { horizontal_grow = true, recruits_subbutton } }
-- main dialog definition
local dialog = {
T.tooltip { id = "tooltip_large" },
T.helptip { id = "tooltip_large" },
T.grid(filter_false {
T.row { T.column { T.spacer { width = 250 } } },
description_row,
T.row { T.column { horizontal_grow = true, listbox } },
T.row { T.column { horizontal_grow = true, unit_button } },
recruits_button,
T.row { T.column { horizontal_grow = true, (unit_override_one or game_override_one) and reset_button or T.spacer { width = 250 } } },
})
}
-- dialog preshow function
local function preshow()
for i, advance_type in ipairs(options) do
local text = advance_type.name
if advance_type.id == game_override_one then
text = text .. _" (chosen, all)"
elseif advance_type.id == unit_override_one then
text = text .. _" (chosen)"
end
wesnoth.set_dialog_value(text, "the_list", i, "the_label")
local img = advance_type.__cfg.image
wesnoth.set_dialog_value(img or "misc/blank-hex.png", "the_list", i, "the_icon")
end
wesnoth.set_dialog_focus("the_list")
local function select()
local i = wesnoth.get_dialog_value "the_list"
if i > 0 then
local img = options[i].__cfg.image
wesnoth.set_dialog_value(img or "misc/blank-hex.png", "the_list", i, "the_icon")
end
end
wesnoth.set_dialog_callback(select, "the_list")
end
-- dialog postshow function
local item_result
local function postshow()
item_result = wesnoth.get_dialog_value("the_list")
end
local dialog_exit_code = wesnoth.show_dialog(dialog, preshow, postshow)
-- determine the choice made
local is_reset = dialog_exit_code == reset_code
local is_ok = dialog_exit_code >= single_unit_code and item_result >= 1
local game_scope = dialog_exit_code == all_units_code
return {
is_unit_override = is_reset or is_ok,
unit_override = is_ok and options[item_result].id or is_reset and table.concat(unit_type_options, ","),
is_game_override = is_reset or game_scope,
game_override = game_scope and options[item_result].id or nil,
}
end
-- >>

View File

@ -0,0 +1,250 @@
-- << pick_advance/main.lua
local on_event = wesnoth.require("lua/on_event.lua")
local T = wesnoth.require("lua/helper.lua").set_wml_tag_metatable {}
local _ = wesnoth.textdomain "wesnoth"
wesnoth.wml_actions.set_menu_item {
id = "pickadvance",
description = _"Pick Advance",
T.show_if {
T.lua {
code = "return pickadvance.menu_available()"
},
},
T.command {
T.lua {
code = "pickadvance.pick_advance()"
}
}
}
-- replace any non-alphanumeric characters with an underscore
local function clean_type_func(unit_type)
return string.gsub(unit_type, "[^a-zA-Z0-9]", "_")
end
-- splits a comma delimited string of unit types
-- returns a table of unit types that aren't blank, "null", and that exist
local function split_comma_units(string_to_split)
local result = {}
local n = 1
for s in string.gmatch(string_to_split or "", "[^,]+") do
if s ~= "" and s ~= "null" and wesnoth.unit_types[s] then
result[n] = s
n = n + 1
end
end
return result
end
-- returns a table of the original unit types
-- a comma delimited string containing the same values
local function original_advances(unit)
local clean_type = clean_type_func(unit.type)
local variable = unit.variables["pickadvance_orig_" .. clean_type] or ""
return split_comma_units(variable), clean_type_func(variable)
end
-- replace the unit's current advancements with the new set of units via object/effect
local function set_advances(unit, array)
wesnoth.add_modification(unit, "object", {
id = "pickadvance",
take_only_once = false,
T.effect {
apply_to = "new_advancement",
replace = true,
types = table.concat(array, ",")
}
})
end
-- for table "arr" containing sets of [index,unit_type]
-- return table containing sets of [unit_type,true]
local function array_to_set(arr)
local result = {}
for _, v in ipairs(arr) do
result[v] = true
end
return result
end
-- for table "arr" containing sets of [unit_type,true]
-- return table containing sets of [index,unit_type]
local function array_filter(arr, func)
local result = {}
for _, v in ipairs(arr) do
if func(v) then
result[#result + 1] = v
end
end
return result
end
-- works as anti-cheat and fixes tricky bugs in [male]/[female]/undead variation overrides
local function filter_overrides(unit, overrides)
local possible_advances_array = original_advances(unit)
local possible_advances = array_to_set(possible_advances_array)
local filtered = array_filter(overrides, function(e) return possible_advances[e] end)
return #filtered > 0 and filtered or possible_advances_array
end
-- returns a table with the unit's original advancements
-- the unit's currently overridden advancement or nil if not set
-- the unit's currently overridden advancement or nil if not set, but set by some other mechanism from the current game
local function get_advance_info(unit)
local type_advances, orig_options_sanitized = original_advances(unit)
local game_override_key = "pickadvance_side" .. unit.side .. "_" .. orig_options_sanitized
local game_override = wesnoth.get_variable(game_override_key)
local function correct(override)
return override and #override > 0 and #override < #type_advances and override or nil
end
return {
type_advances = type_advances,
unit_override = correct(unit.advances_to),
game_override = correct(split_comma_units(game_override)),
}
end
-- true if there's a unit at the selected hex
-- the unit has advancements
-- the unit is on a local human controlled side
-- the unit has multiple options in either its original set of advancements or current set of advancements
function pickadvance.menu_available()
local unit = wesnoth.get_unit(wml.variables.x1, wml.variables.y1)
return unit and
#unit.advances_to > 0
and wesnoth.sides[unit.side].is_local and wesnoth.sides[unit.side].controller == "human"
and (#original_advances(unit) > 1 or #unit.advances_to > 1)
end
-- if the unit doesn't have a set of original advancements present, remove any existing "pickadvance" object
-- set the unit's original advancements in its variables
-- and then set the unit's advancement to either a game-provided override or its default advancements
local function initialize_unit(unit)
local clean_type = clean_type_func(unit.type)
if unit.variables["pickadvance_orig_" .. clean_type] == nil then
wesnoth.wml_actions.remove_object {
object_id = "pickadvance",
id = unit.id
}
unit.variables["pickadvance_orig_" .. clean_type] = table.concat(unit.advances_to, ",")
local advance_info = get_advance_info(unit)
local desired = advance_info.game_override or unit.advances_to
desired = filter_overrides(unit, desired)
set_advances(unit, desired)
end
end
-- let the player select the unit's advancement via dialog
function pickadvance.pick_advance(unit)
unit = unit or wesnoth.get_unit(wml.variables.x1, wml.variables.y1)
initialize_unit(unit)
local _, orig_options_sanitized = original_advances(unit)
local dialog_result = wesnoth.synchronize_choice(function()
local local_result = pickadvance.show_dialog_unsynchronized(get_advance_info(unit), unit)
return local_result
end, function() return { is_ai = true } end)
if dialog_result.is_ai then
return
end
dialog_result.unit_override = split_comma_units(dialog_result.unit_override)
dialog_result.game_override = split_comma_units(dialog_result.game_override)
dialog_result.unit_override = filter_overrides(unit, dialog_result.unit_override)
dialog_result.game_override = filter_overrides(unit, dialog_result.game_override)
if dialog_result.is_unit_override then
set_advances(unit, dialog_result.unit_override)
end
if dialog_result.is_game_override then
local key = "pickadvance_side" .. unit.side .. "_" .. orig_options_sanitized
wesnoth.set_variable(key, table.concat(dialog_result.game_override, ","))
end
end
-- make unit advancement tree viewable in the ingame help
local known_units = {}
local function make_unit_known(unit) -- can be both unit or unit type
local type = unit.type or unit.id
if known_units[type] then return end
known_units[type] = true
wesnoth.add_known_unit(type)
for _, advance in ipairs(unit.advances_to) do
make_unit_known(wesnoth.unit_types[advance])
end
end
-- initialize a unit for picking an advancement
-- make its advancements viewable
-- force picking an advancement if it has multiple and the force option was specified
local function initialize_unit_x1y1(ctx)
local unit = wesnoth.get_unit(ctx.x1, ctx.y1)
if not wesnoth.sides[unit.side].__cfg.allow_player then return end
initialize_unit(unit)
make_unit_known(unit)
if #unit.advances_to > 1 and wml.variables.pickadvance_force_choice and unit.side == wesnoth.current.side then
pickadvance.pick_advance(unit)
end
end
-- return true if the side can be played and has either a recruit list set or non-leader units
local function humans_can_recruit()
for _, side in ipairs(wesnoth.sides) do
local units = wesnoth.get_units { side = side.side, canrecruit = false }
if side.__cfg.allow_player and (#side.recruit ~= 0 or #units > 0) then
return true
end
end
end
-- return true if any keeps exist
local function map_has_keeps()
local width,height,_ = wesnoth.get_map_size()
for x = 1, width do
for y = 1, height do
local terr = wesnoth.get_terrain(x, y)
local info = wesnoth.get_terrain_info(terr)
if info.keep then
return true
end
end
end
end
-- on start determine whether choosing an advancement is force for each unit
on_event("start", function()
local map_has_recruits = humans_can_recruit() and map_has_keeps()
wml.variables.pickadvance_force_choice = wml.variables.pickadvance_force_choice or not map_has_recruits
end)
-- set "fresh_turn" for the moveto event at the start of each side turn
local fresh_turn = false
on_event("turn refresh", function()
fresh_turn = true
end)
-- the first time a unit moves at the start of each side's turn, check if there are any new units that need to be forced to make an advancement choice
on_event("moveto", function()
if fresh_turn then
fresh_turn = false
if not wesnoth.sides[wesnoth.current.side].__cfg.allow_player then return end
for _, unit in ipairs(wesnoth.get_units { side = wesnoth.current.side }) do
if #unit.advances_to > 1 and wml.variables.pickadvance_force_choice and wesnoth.current.turn > 1 then
pickadvance.pick_advance(unit)
if #unit.advances_to > 1 then
local len = #unit.advances_to
local rand = wesnoth.random(len)
unit.advances_to = { unit.advances_to[rand] }
end
else
initialize_unit(unit)
end
end
end
end)
-- initialize units on recruit and after advancing, forcing another advancement choice if required
on_event("recruit", initialize_unit_x1y1)
on_event("post advance", initialize_unit_x1y1)
-- >>

View File

@ -0,0 +1,68 @@
# Unit tests for wesnoth.as_text(...)
{GENERIC_UNIT_TEST "as_text" (
[event]
name = prestart
[set_variables]
name=var
[value]
[one]
[first]
a=1
b="5"
c=true
[/first]
[/one]
[two]
[second]
x=9
y="3"
z=false
[/second]
[/two]
[/value]
[/set_variables]
[lua]
code = <<
local function assert_equal(source, result)
if source ~= result then
-- Fail the test
wesnoth.wml_actions.endlevel({test_result = "fail", linger_mode = true})
end
end
local function assert_contains(source, fragment)
if not string.find(source, fragment, 1, true) then
-- Fail the test
wesnoth.wml_actions.endlevel({test_result = "fail", linger_mode = true})
end
end
assert_equal(wesnoth.as_text("a"), '"a"')
assert_equal(wesnoth.as_text(1), "1")
assert_equal(wesnoth.as_text(true), "true")
assert_equal(wesnoth.as_text({ "a", "b", "c" }), '{"1":"a","2":"b","3":"c"}')
-- associative table iteration order not defined and can vary between runs even when the data remains identical
local tab_txt = wesnoth.as_text({ a = 1, b = false, c = "d" })
assert_contains(tab_txt, '"a":1')
assert_contains(tab_txt, '"b":false')
assert_contains(tab_txt, '"c":"d"')
local wml_tab_txt = wesnoth.as_text(wml.variables["var"])
assert_contains(wml_tab_txt, '{"1":{"1":"one","2":{"1":{"1":"first","2":{')
assert_contains(wml_tab_txt, '"a":1')
assert_contains(wml_tab_txt, '"b":5')
assert_contains(wml_tab_txt, '"c":true')
assert_contains(wml_tab_txt, ',"2":{"1":"two","2":{"1":{"1":"second","2":{')
assert_contains(wml_tab_txt, '"x":9')
assert_contains(wml_tab_txt, '"y":3')
assert_contains(wml_tab_txt, '"z":false')
-- Pass the test. Doesn't do anything if any of the above assertions has failed.
wesnoth.wml_actions.endlevel({test_result = "pass", linger_mode = true})
>>
[/lua]
[/event]
)}

View File

@ -115,6 +115,7 @@
0 test_wml_actions
0 test_wml_conditionals
0 lua_wml_tagnames
0 as_text
#
# Pathfinding
#