Give a list one struct type so every item keeps its shape (#254)
game-maker / gate (push) Successful in 3m28s
game-maker / suite (push) Successful in 10m38s

A list of structs used to let every item carry its own fields. Adding
an item meant typing the same fields again, and nothing kept the items
in step with the struct asset from #253. A list can now name a struct
type for its items, and the asset keeps them in shape.

- A list variable and a list field get the row Items with Mixed, every
  struct asset and Make struct type. Make struct type collects every
  field any item has, so no item loses a field.
- A typed item carries its type under the key `$struct`
  (ITEM_TYPE_KEY). listItems does not count it as a field.
- A list that holds anything but structs refuses a type, also when
  the offending item sits in an override. The console names the list
  and the count, and the list stays as it was.
- In a typed list `+ Item` adds an item with the defaults, without the
  kind menu. Field names are locked, values stay editable.
- The rows of a typed item take their kind from the struct asset
  (declaredKind in InlineList.ts). An asset field shows the asset
  picker instead of a text field, as reported in the issue.
- A struct item in a mixed list gets its own Type row. Switching a
  list back to Mixed keeps the type on each item.
- syncStructTypes brings marked items into shape and carries renames
  into them, also inside overrides and nested lists. One undo step.
- The delete dialog of a struct asset also counts typed lists.
- The game never sees the key. World and sharedState strip it with
  withoutItemTypes and bagWithoutItemTypes, so a struct stays a
  plain object at runtime.
- Add item and Set item get the setting Type. With a type they only
  accept a struct with exactly its field names (fitsType in
  SetItem.ts). Anything else is left out and logged.
- The item indent from #252 is back to its old width, the narrow one
  squeezed the value fields.

Not done: writing a variable does not check the type, and the List
node offers no type.

Found on the way: a test helper in list-of-structs.test.ts called
nodes without params. It now passes the same arguments as the
interpreter.

Tests:
- ./check.sh: 183 files, 2679 tests, CHECK EXIT 0
- e2e GM_STEPS=1,21,22,32,54,55: ALL PASSED, 150 checks
- e2e GM_STEPS=1-6,21,22,32,54,55 before the follow-up: 404 checks
- New: tests/core/typed-lists.test.ts, e2e module 55 with 13 checks
- Counter-checks bite in unit and browser, originals restored by cmp

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-09-28 07:23:00 +02:00
1 parent 61bbb96a96
commit b0d0868f55
25 files changed
+1446 -86

No files matched your search

+319
View File
@@ -0,0 +1,319 @@
/**
* Typed lists (#254), with the real mouse: a list variable picks a Struct asset under its head and
* its items take the type; a list holding a number refuses and says so in the console; `+ Item`
* adds one of the type straight away; a struct item in a mixed list picks a type of its own; a
* rename on the asset reaches both; "Make struct type" turns a list's items into an asset. In a run
* `Add item` with a type takes an item of that list and a Pack of it, and turns a number away. A
* typed item's asset field is drawn as the asset picker its type declares, not as the text it holds.
*
* Everything it adds carries a `tl55` id or is an asset it made, and goes again at the end.
*/
import { inView, MAKE_OBJECT } from './lib/scaffold.mjs';
export async function typedLists(t) {
const { check, json, evaluate, mutate, click, press, typeText, sleep, shot, select, undo, stop } = t;
const press_ = async (at, what) => {
if (!at) throw new Error(`nothing to click: ${what}`);
await click(at.x, at.y);
await sleep(150);
};
/** A menu entry, always the last drawn: the panels behind a menu carry the same words. */
const pick = async (label) => press_(await json(`__gm.locate(${JSON.stringify(label)}, true)`), label);
/** Every hit of a text, top to bottom. */
const all = async (text) =>
((await json(`__gm.locateAll(${JSON.stringify(text)})`)) ?? []).sort((a, b) => a.y - b.y);
const asset = (id) => json(`__gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)}) ?? null`);
const chest = () => asset('tl55-chest');
const variable = async (key) => (await chest()).variables.find((v) => v.key === key);
/** What the chest holds: its own value once one is set, else the declared default. */
const value = async (key) => {
const now = await chest();
return now.props[key] ?? now.variables.find((v) => v.key === key).default;
};
/** Per drawn item of a list in the inspector: whether its fields are the type's, fixed. */
const locked = async (key) =>
((await json("__gm.values.lists('inspector')")) ?? []).find((l) => l.title === list(key))?.locked ?? null;
const list = (key) => `Chest 55 · ${key}`;
const MARK = '$struct';
const LOOT = 'tl55-loot';
const DROP = 'tl55-drop';
const node = (id, type, x, y, params = {}, ports) => ({
id,
type,
x,
y,
params,
...(ports ? { ports } : {}),
});
const edge = (id, from, fromPort, to, toPort) => ({
id,
from: { node: from, port: fromPort },
to: { node: to, port: toPort },
});
const fields = [
{ key: 'Name', kind: 'text' },
{ key: 'Amount', kind: 'number' },
];
// `loots` = two structs, `mixed` = a struct and a number, `bags` = structs of two shapes, `drops` =
// one item of a type with an asset field. The graph adds an item of `loots`, a packed Loot and a
// number to `loots`, each through a typed Add item, then tries the number in a typed Set item.
const graph = {
nodes: [
node('tl55-start', 'OnStart', -400, 0),
node('tl55-vars', 'Variables', -400, 160, {}, [{ key: 'loots', kind: 'list' }]),
node('tl55-at', 'ItemAt', -200, 160, { index: 0 }),
node('tl55-add1', 'AddItem', 0, 160, { structType: LOOT }),
node('tl55-pack', 'PackStruct', -200, 320, { structType: LOOT, Name: 'Bow', Amount: 2 }, fields),
node('tl55-add2', 'AddItem', 200, 160, { structType: LOOT }),
node('tl55-num', 'Number', 0, 480, { value: 9 }),
node('tl55-add3', 'AddItem', 400, 160, { structType: LOOT }),
node('tl55-set', 'SetItem', 600, 160, { structType: LOOT, index: 0 }),
node('tl55-count', 'Count', 800, 160),
node('tl55-log', 'Log', 1000, 0),
],
edges: [
edge('tl55-e1', 'tl55-start', 'exec', 'tl55-log', 'exec'),
edge('tl55-e2', 'tl55-vars', 'loots', 'tl55-at', 'list'),
edge('tl55-e3', 'tl55-vars', 'loots', 'tl55-add1', 'list'),
edge('tl55-e4', 'tl55-at', 'item', 'tl55-add1', 'value'),
edge('tl55-e5', 'tl55-add1', 'result', 'tl55-add2', 'list'),
edge('tl55-e6', 'tl55-pack', 'value', 'tl55-add2', 'value'),
edge('tl55-e7', 'tl55-add2', 'result', 'tl55-add3', 'list'),
edge('tl55-e8', 'tl55-num', 'value', 'tl55-add3', 'value'),
edge('tl55-e9', 'tl55-add3', 'result', 'tl55-set', 'list'),
edge('tl55-e11', 'tl55-num', 'value', 'tl55-set', 'value'),
edge('tl55-e12', 'tl55-set', 'result', 'tl55-count', 'list'),
edge('tl55-e10', 'tl55-count', 'count', 'tl55-log', 'message'),
],
};
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
p.assets.push({
id: '${LOOT}',
type: 'struct',
name: 'Loot 55',
props: { Name: 'Coin', Amount: 1 },
variables: [
{ key: 'Name', label: 'Name', kind: 'text', default: '' },
{ key: 'Amount', label: 'Amount', kind: 'number', default: 0 },
],
});
p.assets.push({
id: '${DROP}',
type: 'struct',
name: 'Drop 55',
props: { Name: '', Drop: '' },
variables: [
{ key: 'Name', label: 'Name', kind: 'text', default: '' },
{ key: 'Drop', label: 'Drop', kind: 'asset', default: '' },
],
});
p.assets.push({
id: 'tl55-chest',
type: 'behavior',
name: 'Chest 55',
props: { enabled: true, runInEditor: false },
variables: [
{ key: 'loots', label: 'loots', kind: 'list', default: [{ Name: 'Gem', Amount: 4 }, { Name: 'Axe' }] },
{ key: 'mixed', label: 'mixed', kind: 'list', default: [{ Name: 'Own' }, 3] },
{ key: 'bags', label: 'bags', kind: 'list', default: [{ Gold: 5 }, { Gold: 2, Gems: 1 }] },
{
key: 'drops',
label: 'drops',
kind: 'list',
itemType: '${DROP}',
default: [{ Name: 'Gold', Drop: '', [${JSON.stringify('$struct')}]: '${DROP}' }],
},
],
graph: ${JSON.stringify(graph)},
});
${MAKE_OBJECT}
scene.objects.push(make('tl55-obj', 'tl55-chest', '', 400, 'Chest 55'));
`);
// 55a. The Items row under a list names its type: Mixed, until Loot 55 is picked – then every item
// is a Loot, keeping what fits and taking the type's defaults for the rest.
await select({ kind: 'asset', assetId: 'tl55-chest' });
await sleep(200);
const rows = await all('Mixed');
check('each list variable has an Items row reading "Mixed"', rows.length === 3, JSON.stringify(rows));
await press_(rows[0], 'Items of loots');
await pick('Loot 55');
const typed = { itemType: (await variable('loots')).itemType, items: await value('loots') };
check(
'picking Loot 55 types loots: Gem keeps its 4, Axe takes the default Amount 1, both marked',
JSON.stringify(typed) ===
JSON.stringify({
itemType: LOOT,
items: [
{ Name: 'Gem', Amount: 4, [MARK]: LOOT },
{ Name: 'Axe', Amount: 1, [MARK]: LOOT },
],
}),
JSON.stringify(typed),
);
await shot('01-typed-list');
// 55b. A list holding a number cannot take a type: the console says why, the list stays mixed.
await evaluate('__gm.console.clear()');
await press_((await all('Mixed'))[0], 'Items of mixed');
await pick('Loot 55');
await sleep(300);
const lines = ((await json('__gm.console.lines()')) ?? []).map((l) => l.text);
const refused = 'mixed holds 1 item that is no struct – only a list of structs can take a type';
check(
'typing a list with a number in it is refused, in the console, and nothing changes',
lines.includes(refused) &&
(await variable('mixed')).itemType === undefined &&
JSON.stringify(await value('mixed')) === JSON.stringify([{ Name: 'Own' }, 3]),
JSON.stringify({ lines, mixed: await value('mixed') }),
);
// 55c. + Item on the typed list adds a Loot at its defaults, no kind menu in between. A typed item
// has the type's fields, fixed; the mixed list's struct item keeps its own.
const head = await json(`__gm.values.headAt('inspector', ${JSON.stringify(list('loots'))})`);
await press_(head?.add, '+ Item of loots');
const added = (await value('loots'))[2];
check(
'+ Item on a typed list adds a Loot at its defaults straight away',
JSON.stringify(added) === JSON.stringify({ Name: 'Coin', Amount: 1, [MARK]: LOOT }) &&
((await json('__gm.menuRows()')) ?? []).length === 0,
JSON.stringify(added),
);
const drawn = { loots: await locked('loots'), mixed: await locked('mixed') };
check(
'the typed items are drawn with fixed fields, the struct item of the mixed list with its own',
JSON.stringify(drawn) === JSON.stringify({ loots: [true, true, true], mixed: [false, false] }),
JSON.stringify(drawn),
);
// 55d. The struct item of the mixed list picks a type of its own; the number beside it stays.
await press_(
await json(`__gm.values.typeAt('inspector', ${JSON.stringify(list('mixed'))}, 0)`),
'Type of mixed[0]',
);
await pick('Loot 55');
check(
'the struct item in the mixed list becomes a Loot with fixed fields, the number beside it stays',
JSON.stringify(await value('mixed')) === JSON.stringify([{ Name: 'Own', Amount: 1, [MARK]: LOOT }, 3]) &&
JSON.stringify(await locked('mixed')) === JSON.stringify([true, false]),
JSON.stringify(await value('mixed')),
);
await shot('02-item-type');
// 55e. A field renamed on the asset reaches the typed list and the typed item, values kept.
await select({ kind: 'asset', assetId: LOOT });
await sleep(200);
const amount = (await all('Amount')).sort((a, b) => b.x - a.x)[0];
await press_(amount, 'the Amount name');
await press('a', { ctrl: true });
await typeText('Count');
await press('Enter');
await sleep(200);
const renamed = { loots: await value('loots'), mixed: (await value('mixed'))[0] };
check(
'renaming Amount to Count reaches every Loot item, typed list and mixed list alike',
JSON.stringify(renamed) ===
JSON.stringify({
loots: [
{ Name: 'Gem', Count: 4, [MARK]: LOOT },
{ Name: 'Axe', Count: 1, [MARK]: LOOT },
{ Name: 'Coin', Count: 1, [MARK]: LOOT },
],
mixed: { Name: 'Own', Count: 1, [MARK]: LOOT },
}),
JSON.stringify(renamed),
);
await undo();
check(
'one undo takes it back in both',
JSON.stringify((await value('loots'))[0]) === JSON.stringify({ Name: 'Gem', Amount: 4, [MARK]: LOOT }) &&
JSON.stringify((await value('mixed'))[0]) === JSON.stringify({ Name: 'Own', Amount: 1, [MARK]: LOOT }),
JSON.stringify({ loots: await value('loots'), mixed: await value('mixed') }),
);
// 55f. Make struct type on a list of two shapes: every field either has, at its first value.
await select({ kind: 'asset', assetId: 'tl55-chest' });
await sleep(200);
await press_((await all('Mixed')).at(-1), 'Items of bags');
await pick('Make struct type');
const bags = await variable('bags');
const made = await asset(bags.itemType ?? '');
check(
'Make struct type makes "bags" of Gold and Gems and types the list with it',
made?.type === 'struct' &&
made.name === 'bags' &&
JSON.stringify(made.props) === '{"Gold":5,"Gems":1}' &&
JSON.stringify(await value('bags')) ===
JSON.stringify([
{ Gold: 5, Gems: 1, [MARK]: made.id },
{ Gold: 2, Gems: 1, [MARK]: made.id },
]),
JSON.stringify({ bags, made, value: await value('bags') }),
);
await shot('03-make-type');
// 55h. An item keeps an asset id as a plain text – its type says it is an asset: drawn with the
// asset mark and picked from the asset menu, the id stays a plain text in the item.
const drops = list('drops');
const kindsDrawn = ((await json("__gm.values.lists('inspector')")) ?? []).find(
(l) => l.title === drops,
)?.fieldKinds;
check(
'the fields of a typed item are drawn as the kinds its type declares: Name a text, Drop an asset',
JSON.stringify(kindsDrawn) === JSON.stringify([{ Name: 'text', Drop: 'asset' }]),
JSON.stringify(kindsDrawn),
);
const dropField = await inView(
t,
'__gm.inspectorRect()',
`__gm.values.fieldAt('inspector', ${JSON.stringify(drops)}, 0, 'Drop')`,
);
await press_(dropField, 'the Drop field of drops[0]');
const offered = ((await json('__gm.menuRows()')) ?? []).filter((row) => row && row !== 'None');
if (offered[0]) await pick(offered[0]);
const dropped = (await value('drops'))[0];
const target = await asset(typeof dropped?.Drop === 'string' ? dropped.Drop : '');
check(
'clicking the Drop field opens the asset menu, and the picked asset lands as its id',
offered.length > 0 && target !== null && offered[0].startsWith(target.name) && dropped.Name === 'Gold',
JSON.stringify({ offered: offered.slice(0, 3), dropped }),
);
await shot('04-asset-field');
// 55g. In a run the items arrive without the mark: Add item typed Loot 55 takes item 0 of loots
// and the packed Bow, and turns the 9 away with a line in the console – so does Set item.
const logFrom = t.consoleLog.length;
await t.play();
await sleep(600);
await stop();
const logged = t.consoleLog
.slice(logFrom)
.filter((line) => line.startsWith('[behavior Chest 55]'))
.map((line) => line.split(']').pop().trim());
check(
'a typed Add item takes a Loot of the list and a packed one, and reports the 9 without adding it',
logged.includes('Add item: 9 is no Loot 55 – not added') && logged.at(-1) === '5',
JSON.stringify(logged),
);
check(
'a typed Set item reports the 9 too and leaves the item it would replace',
JSON.stringify(logged) ===
JSON.stringify(['Add item: 9 is no Loot 55 – not added', 'Set item: 9 is no Loot 55 – not set', '5']),
JSON.stringify(logged),
);
// Clean up.
await evaluate('__gm.state.select(null)');
await mutate(`
const p = __gm.state.project;
const scene = __gm.state.activeScene;
scene.objects = scene.objects.filter((o) => o.id !== 'tl55-obj');
const made = ${JSON.stringify(made?.id ?? '')};
p.assets = p.assets.filter(
(a) => a.id !== 'tl55-chest' && a.id !== '${LOOT}' && a.id !== '${DROP}' && a.id !== made,
);
`);
}
+2
View File
@@ -57,6 +57,7 @@ import { clipboard } from './51-clipboard.mjs';
import { hierarchyGroups } from './52-hierarchy-groups.mjs';
import { curves } from './53-curves.mjs';
import { structTypes } from './54-struct-types.mjs';
import { typedLists } from './55-typed-lists.mjs';
export const STEPS = [
hierarchy,
@@ -113,4 +114,5 @@ export const STEPS = [
hierarchyGroups,
curves,
structTypes,
typedLists,
];