diff --git a/docs/commands.md b/docs/commands.md index 16ff58e..a60e5ef 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -193,6 +193,10 @@ OUT/videos/.json each video of the episode, as WebM (`story.video` OUT/storychat/default.json the shared chat rows, when the episode shows chat (`story.chat`) OUT/storyui/.json the story UI in each requested language (`story.ui`) OUT/storyfonts/_.json the episode's font records and glyph pages (`story.fonts`) +OUT/storysimpleui/.json the Overlay talk-window and system-message UI (`story.simpleui`) +OUT/storyhosts/.json the Overlay host screen (`story.host`) +OUT/spots/.json a home host's spot and Spine files (`spot.files`) +OUT/rooms/.json a home host's background (`room.gltf`) OUT/crilips/default.json the CRI Lips analysis data, when a voice can reach it (`story.crilips`) OUT/assets/. the files the manifests name ``` @@ -216,6 +220,10 @@ images. `--fonts game` uses the client's TMP glyphs and generates the runtime gl Both sources use font settings from the client's assets and require the `fonts` extra. Font files are explicit content-addressed inputs, so changing their bytes rebuilds the corresponding font product. +Overlay episodes also request their home or live-result host, the simple UI in each requested language, and, +for home hosts, the shared spot and room products. The host's master-data relationship is recorded in +`story.json` `host`; the episode font product covers simple UI text and any area title in both font modes. + `--format` (default `aac`) is the web audio format of the cue sheets: `aac` (AAC-LC in MP4, `cues.json` records each file's encoder delay), `opus`, `vorbis`, `mp3`, or `flac` (the decoded FLAC as it is); `--flac-level` is the FLAC compression level of the decoded sounds (as for `export audio`). `crilips/` holds the weights of the game's CRI Lips diff --git a/docs/contracts.md b/docs/contracts.md index b8b21af..d3073d1 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -19,6 +19,10 @@ Live start UI data uses the `format` field `nnnotes.live-ui/1`, described in [live-ui.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/live-ui.schema.json) and [live start UI products](products.md#live-start-ui). +Overlay story products use `nnnotes.story-host/1` and `nnnotes.story-simple-ui/1` in their `format` fields: +[story-host.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story-host.schema.json) +and [story-simple-ui.schema.json](https://github.com/MetaSekaiLab/nnnotes/blob/main/src/nnnotes/schemas/story-simple-ui.schema.json). + Stages carry their own version. A change of what a stage writes for the same inputs is a new stage version, which changes the key of every task of that stage (golden tests fail when a stage's output changes while its version stays). diff --git a/docs/export.md b/docs/export.md index 3fbb9b3..c5c72c5 100644 --- a/docs/export.md +++ b/docs/export.md @@ -26,6 +26,9 @@ themselves are under `assets/`. files from `[paths.fonts]` or repeatable `--font LANG=PATH` flags; supply any languages needed by the fallback chain. Both font sources require the `fonts` extra. +Overlay episodes also export their home or live-result host and the simple talk-window UI. Home hosts share the +spot's Spine files and room glTF products. Their area title and simple UI text use the episode's font product. + Products that several stories share (the scene, assets, models and cue sheets) are placed once, so exporting several stories into one directory keeps one copy of each. Every product goes through the depot, so a second export reuses its results. diff --git a/docs/products.md b/docs/products.md index 670fc88..7953e01 100644 --- a/docs/products.md +++ b/docs/products.md @@ -183,6 +183,10 @@ texture a material's main slot names is one glTF texture with its own sampler (f mip count), and textures whose PNG bytes are equal share one glTF image. The `textures` of `room.json` has one entry per glTF texture, in glTF order: the Unity texture's name. +`room.json` also records `nodes`, each node's prefab `path` and Mesh object id in glTF node order, and +`materialIds`, Material object ids in glTF material order. These identify the serialized objects when the room +is displayed with a host scene's activation and material state. + What draws nothing is not written and `room.json` lists it: a submesh whose material slot is empty (`nullMaterialSubmeshes`: `mesh`, object `path` in the prefab, `submesh` index), a MeshFilter without a mesh (`nullMeshFilters`: the object path) and a mesh without vertices or triangles (`emptyMeshes`: `mesh` and `path`). @@ -466,6 +470,31 @@ The retained object table is the UI closure, so the font assets' files remain in `font-bundles` names the bundles holding the font assets, and `apk:AndroidManifest.xml` supplies the client version. A client requests the product for each language it uses. +### Overlay hosts and simple UI + +For episodes with `MasterAdv._playbackMode` 1, `story.json` includes a `host` descriptor. The host is selected +from one matching `MasterStoryHomeSpotTapTalkEpisode`, `MasterHomeSpot` or `MasterStoryLiveResultEpisode` row. +It identifies the host kind and subject, the home talk kind and character where applicable, and the area title +in each language. A missing or ambiguous host is an error. + +`parts.host` maps its subject to `storyhosts/home_.json` or `storyhosts/afterlive.json`. A home host also +uses `parts.spots` and `parts.rooms`, whose keys are the situation and background addresses. These name the shared +`spot.files` and `room.gltf` products. + +`story.host` writes `host.json` with format `nnnotes.story-host/1`. `prefabs` identifies the host's UI, capture +camera, background and situation objects. `overlayRoot` and `openedSequences` are `{prefab, path}` locations; +`layoutRoot` also carries the serialized `SimpleAdvLayoutRoot` fields. `home` identifies the room and spot +products, the Spot scene roots, and `backgroundState`: activation after selecting the situation, and the floor +renderer's queue. `afterlive` identifies the result background image and sprite, reward panel and substitute +elements. Graphics, DOTween settings, animation bindings and shaders are object references. + +`parts.simpleui` maps each language to `storysimpleui/.json`. `story.simpleui` writes `ui.json` with +format `nnnotes.story-simple-ui/1`: the simple talk-window, system-message and UIManager prefab ids, settings, +text records, animation bindings and shaders. The episode's `story.fonts` product includes these texts and the +area title in either font mode. Font files are read as inputs; the retained UI closure uses fenced font references. + +### Episode fonts + `story.fonts` contains the font records for one episode and language. Its `episode` input is the first run's `episode.json`, supplied in the planning fact `product_inputs` under `episode:`. This snapshot determines the chat-window and frame roots before object export. Its other inputs are localized `MasterText` rows, the font diff --git a/docs/schemas.md b/docs/schemas.md index 54bc85c..b3861fe 100644 --- a/docs/schemas.md +++ b/docs/schemas.md @@ -34,6 +34,8 @@ schemas.load("plan") # the schema as a dict | `run` | `nnnotes.run/1` | a run manifest: context, selection, tasks with keys and statuses, layouts | | `scripts` | `nnnotes.scripts/1` | every MonoScript of the censuses, by object id | | `story-fonts` | `nnnotes.story-fonts/1` (`format`) | an episode's text bindings, font records, glyph pages and text shaders | +| `story-host` | `nnnotes.story-host/1` (`format`) | an Overlay story's host screen, layout and initial state | +| `story-simple-ui` | `nnnotes.story-simple-ui/1` (`format`) | localized Overlay talk-window and system-message prefabs | | `task` | `nnnotes.task/1` | one stage applied to one subject, with everything needed to run it (`depot run`) | | `view` | `nnnotes.view/1` | a semantic view of a master table, each row with its names and the objects it names | diff --git a/docs/stages.md b/docs/stages.md index 9500ac9..53db9e1 100644 --- a/docs/stages.md +++ b/docs/stages.md @@ -45,6 +45,8 @@ codec and channel of a USM) and `catalog.index` (version 2: gzip input and Japan | `story.chat` | `default` | `objects`: the object table of the shared chat shards | `chat.json` | | `story.ui` | a client language (`ja`, `en`, `zh-Hant`, `zh-Hans`, `ko`) | `objects`: UI, font and chat-window objects; `ui.objects`: the retained UI closure; `master`: localized `MasterText` rows; `font-bundles`; `apk:AndroidManifest.xml` ([products](products.md#stories)) | `ui.json` (text styles and chat status texts) and the UI object table | | `story.fonts` | `/` | `objects`; `episode`: the parsed episode snapshot; `master`: localized texts; `font-bundles`; `apk:AndroidManifest.xml`; `font:` and `font:emoji` for parameter `fonts: open` ([products](products.md#stories)) | `fonts.json`, glyph pages and the text shader objects; `fonts: game` uses TMP assets | +| `story.simpleui` | a client language | `objects`: font and simple UI objects; `ui.objects`: retained UI closure; `master`; `font-bundles`; `apk:AndroidManifest.xml` | `ui.json`, localized text styles and the UI object table | +| `story.host` | `home/` or `afterlive` | `objects`: host UI, capture camera and scene; `master`: host row and related product paths | `host.json` with prefab references, layout, opened sequences and home background state | | `story` | a `MasterAdv` id | `objects`: the object table of the episode's command list, its shards and the ADV player settings ([products](products.md#stories)); `master`: the episode's master record | `episode.json` and `story.json` | | `link.addresses` | global | catalog index, censuses (the built-in files' too) | key -> objects (a scene key: the objects of the scene's file outside a hierarchy), Resources.Load path -> objects, built-in selector (class, MonoBehaviour script class) -> objects, serialized file -> bundle or built-in file, bundle -> keys | | `view.` | a catalog (`main`) | `master:` per master table it reads, `rules` (the view's rule and the resolvers it uses), `addresses` (the part of the address table it read: the keys looked up and the prefixes listed); no parameters, atoms or context | `view.:#view` (facts: rows, entries, gaps, unreferenced) | diff --git a/src/nnnotes/cli/assets.py b/src/nnnotes/cli/assets.py index 52fe306..01c07c6 100644 --- a/src/nnnotes/cli/assets.py +++ b/src/nnnotes/cli/assets.py @@ -136,6 +136,8 @@ def matches(self, stage: str) -> bool: StageSource("story.chat", "nnnotes.pipeline.story:ChatStage", output=True, product=True), StageSource("story.ui", "nnnotes.pipeline.story:UiStage", output=True, product=True), StageSource("story.fonts", "nnnotes.pipeline.story:FontsStage", output=True, product=True), + StageSource("story.simpleui", "nnnotes.pipeline.story:SimpleUiStage", output=True, product=True), + StageSource("story.host", "nnnotes.pipeline.storyhost:HostStage", output=True, product=True), StageSource("story", "nnnotes.pipeline.story:StoryStage", output=True, product=True), StageSource("view.", "nnnotes.pipeline.views:stages", output=True), StageSource("view.voices", "nnnotes.pipeline.voices:VoiceStage", output=True), diff --git a/src/nnnotes/content/room.py b/src/nnnotes/content/room.py index f3ce16e..8dfde11 100644 --- a/src/nnnotes/content/room.py +++ b/src/nnnotes/content/room.py @@ -419,7 +419,7 @@ def material_for(oid: str) -> int: return materials[oid] counts = {"meshes": 0, "inactive": 0} - null_slots, null_meshes, empty = [], [], [] + null_slots, null_meshes, empty, nodes = [], [], [], [] for i, node in enumerate(prefab.nodes): comps = {component_class(c): c["fields"] for c in node["components"]} mf, mr = comps.get("MeshFilter"), comps.get("MeshRenderer") @@ -466,6 +466,7 @@ def material_for(oid: str) -> int: uv = mesh.uv if mesh.uv is not None else np.zeros((len(mesh.pos), 2), np.float32) active = prefab.active(i) glb.add_mesh(mesh.name, p, nw, uv, prims, active=active) + nodes.append({"path": path, "mesh": mesh_id}) counts["meshes"] += 1 counts["inactive"] += 0 if active else 1 @@ -477,6 +478,8 @@ def material_for(oid: str) -> int: "nullMaterialSubmeshes": null_slots, "nullMeshFilters": null_meshes, "emptyMeshes": empty, + "nodes": nodes, + "materialIds": list(materials), **glb.summary(), } return glb.bytes({"backgroundKey": key}), doc diff --git a/src/nnnotes/content/storyfontproduct.py b/src/nnnotes/content/storyfontproduct.py index 10f8268..dace25a 100644 --- a/src/nnnotes/content/storyfontproduct.py +++ b/src/nnnotes/content/storyfontproduct.py @@ -8,7 +8,7 @@ from ..unity import fontsettings, textstyle, tmpfont from ..unity.export import MemoryPacker, _safe, packed_png from ..unity.ids import object_id, parse_object_id -from . import advui, storyfonts, storyui, tmpassets +from . import advui, storyfonts, storysimpleui, storyui, tmpassets from .prefabs import component_class, key_object, mono_json, ref_id, shader_table FORMAT = "nnnotes.story-fonts/1" @@ -81,6 +81,9 @@ def shown_texts( """The strings an episode can display, including localized labels and dynamic chat/frame text.""" texts = [row.get(field) for row in episode["text"].values()] texts.append((episode.get("title") or {}).get(field)) + host_title = (episode.get("host") or {}).get("title") or {} + code = next((code for code, (_, column) in languages.LANGUAGES.items() if column[1:] == field), None) + texts.append(host_title.get(code)) texts += [r["text"] for r in storyui.master_id_texts(settings, master).values()] texts += list(status.values()) + advui.chat_runtime_texts(episode) for r in records.values(): @@ -200,6 +203,8 @@ def build( *storyui.window_keys(objects.keys()).values(), *storyui.DIALOG_KEYS.values(), ] + if (episode.get("master") or {}).get("_playbackMode") == 1: + ui_keys += storysimpleui.keys(objects.keys()) records, sprite_bindings, frames = text_records(objects, ui_keys + asset_keys(episode), language, lang, settings) status = storyui.chat_status_texts(objects, master) ids = objects.json(key_object(objects, storyui.MASTER_ID_SETTINGS_KEY, "AdvMasterIdSettings")) diff --git a/src/nnnotes/content/storyhostproduct.py b/src/nnnotes/content/storyhostproduct.py new file mode 100644 index 0000000..987f7e8 --- /dev/null +++ b/src/nnnotes/content/storyhostproduct.py @@ -0,0 +1,285 @@ +"""Host-screen objects and initial state for Overlay stories.""" + +from __future__ import annotations + +from ..source import languages +from . import storyui +from .livenotes import animator_bindings +from .prefabs import ( + Prefab, + builtin_root, + component_class, + component_ref, + has_json, + key_object, + mesh_roles, + ref_id, + shader_table, +) + +FORMAT = "nnnotes.story-host/1" +OVERLAY = 1 +TABLES = ("MasterHomeSpot", "MasterStoryHomeSpotTapTalkEpisode", "MasterStoryLiveResultEpisode", "MasterText") +ADDRESSES = { + "cameraTarget": "Common/Prefab/CameraTargetRenderer", + "spotWidget": "UI/Prefab/UISpotWidget", + "spotLetterbox": "UI/Prefab/Parts/Spot/UISpotLetterbox", + "spotScene": "Scene/Spot", + "resultWidget": "UI/Prefab/UILiveResultWidget", + "resultBackground": "UI/Prefab/UILiveResultBackgroundWidget", + "resultBackgroundSprite": "LowImage/LiveResult/live_result_background", +} +KIND_KEYS = { + "home": ("spotWidget", "spotLetterbox", "spotScene"), + "afterlive": ("resultWidget", "resultBackground", "resultBackgroundSprite"), +} +SUBSTITUTES = ("musicResultPanel", "rewardItems", "memberCard", "navigation", "gradeUpEffect") + + +def episode_host(tables: dict, episode: dict) -> dict | None: + """The host selected by an Overlay episode's unique home or live-result master row.""" + if episode.get("_playbackMode") != OVERLAY: + return None + adv_id = episode["_id"] + hits = [] + for row in tables["MasterStoryHomeSpotTapTalkEpisode"]: + if row["_advId"] == adv_id: + hits.append( + { + "subject": f"home/{row['_spotId']}", + "kind": "home", + "talk": "tap", + "spotId": row["_spotId"], + "characterId": row.get("_characterId") or None, + "group": {"kind": "spotTalk", "id": row["_id"]}, + } + ) + for row in tables["MasterHomeSpot"]: + if row.get("_advId") == adv_id: + texts = [r for r in tables["MasterText"] if r["_id"] == row.get("_advNameTextId")] + if not texts: + raise ValueError(f"home spot {row['_id']}: area title is absent from MasterText") + hits.append( + { + "subject": f"home/{row['_id']}", + "kind": "home", + "talk": "area", + "spotId": row["_id"], + "characterId": None, + "titleTextId": row["_advNameTextId"], + "title": languages.texts(texts[0]), + "group": {"kind": "spot", "id": row["_id"]}, + } + ) + for row in tables["MasterStoryLiveResultEpisode"]: + if row["_advId"] == adv_id: + hits.append( + {"subject": "afterlive", "kind": "afterlive", "group": {"kind": "liveResult", "id": row["_id"]}} + ) + if len(hits) != 1: + raise ValueError(f"Overlay episode {adv_id}: expected one host, found {len(hits)}") + host = hits[0] + if host["kind"] == "home": + row = record(tables, host["subject"])["spot"] + host.update(spotKey=row["_situationAssetPath"], roomKey=row["_backgroundAssetPath"]) + return host + + +def parse_subject(subject: str) -> tuple[str, int | None]: + if subject == "afterlive": + return subject, None + kind, sep, number = subject.partition("/") + if kind != "home" or not sep or not number.isdigit() or str(int(number)) != number: + raise ValueError(f"{subject!r}: expected afterlive or home/") + return kind, int(number) + + +def record(tables: dict, subject: str) -> dict: + kind, spot_id = parse_subject(subject) + if kind == "afterlive": + return {"kind": kind} + rows = [r for r in tables["MasterHomeSpot"] if r["_id"] == spot_id] + if len(rows) != 1: + raise ValueError(f"home spot {spot_id}: expected one MasterHomeSpot row") + return {"kind": kind, "spot": rows[0]} + + +def addresses(record: dict, catalog) -> dict[str, str]: + held, out = set(catalog), {} + for name in ("cameraTarget", *KIND_KEYS[record["kind"]]): + address = ADDRESSES[name] + key = next((k for k in (address, f"Emb{address}") if k in held), None) + if key is None: + if name == "spotScene": + continue + raise ValueError(f"{address}: no plain or embedded asset in the catalog") + out[name] = key + if record["kind"] == "home": + out.update(background=record["spot"]["_backgroundAssetPath"], situation=record["spot"]["_situationAssetPath"]) + return out + + +def _component(objects, prefab: str, cls: str, *, path: str | None = None): + hits = [ + (i, c) + for i, n in enumerate(objects.json(prefab)["nodes"]) + for c in n["components"] + if (path is None or n["path"] == path) and component_class(c) == cls + ] + if len(hits) != 1: + raise ValueError(f"{prefab}: expected one {cls} at {path!r}, found {len(hits)}") + return hits[0] + + +def _target(objects, prefab: str, ref) -> dict: + if ref_id(ref) is not None: + hit = component_ref(objects, prefab, ref) + if hit is None: + raise ValueError("host component target is null") + holder, index, _ = hit + else: + holder = prefab + index = Prefab(objects.json(prefab)).node_of(ref) + if index is None: + raise ValueError("host node target is null") + return {"prefab": holder, "path": objects.json(holder)["nodes"][index]["path"]} + + +def background_state(doc: dict, situation_name: str) -> dict: + """Background activation and the floor renderer's queue after the host places a situation.""" + prefab = Prefab(doc) + root = prefab.nodes[0] + overrides, activated, hidden = {}, [], [] + name = situation_name.replace("(Clone)", "") + matches = [c for c in root["components"] if component_class(c) == "SpotBackground"] + matched = False + if matches: + children = [i for i, p in enumerate(prefab.parent) if p == 0] + if not children: + raise ValueError("SpotBackground needs a child container") + for entry in matches[0]["fields"].get("_situationObjects") or []: + if entry.get("SituationName") == name: + matched = True + activated = [i for i, p in enumerate(prefab.parent) if p == children[0]] + hidden = [prefab.node_of(r) for r in entry.get("HideObjects") or []] + overrides.update({i: True for i in activated}) + overrides.update({i: False for i in hidden if i is not None}) + break + + def active(index): + while index is not None: + if not overrides.get(index, prefab.nodes[index].get("active", True)): + return False + index = prefab.parent[index] + return True + + floor = next( + (i for i, n in enumerate(prefab.nodes) if i and "floor" in n["name"] and "shadow" not in n["name"]), None + ) + renderer = None + if floor is not None: + path = prefab.nodes[floor]["path"] + renderer = next( + ( + i + for i, n in enumerate(prefab.nodes) + if (n["path"] == path or n["path"].startswith(path + "/")) + and active(i) + and any(component_class(c) == "MeshRenderer" for c in n["components"]) + ), + None, + ) + overrides[0] = True + return { + "situation": { + "name": name, + "matched": matched, + "activate": [prefab.nodes[i]["path"] for i in activated], + "hide": [None if i is None else prefab.nodes[i]["path"] for i in hidden], + }, + "nodes": { + n["path"]: {"active": active(i), "renderQueue": 2000 if i == renderer else None} + for i, n in enumerate(prefab.nodes) + }, + "floor": { + "object": None if floor is None else prefab.nodes[floor]["path"], + "renderer": None if renderer is None else prefab.nodes[renderer]["path"], + "renderQueue": 2000, + }, + } + + +def host(objects, record: dict, parts: dict) -> tuple[dict, set, dict]: + """host.json, retained object ids and graphics-API roles from object data.""" + keys = addresses(record, objects.keys()) + roots = { + name: key_object(objects, key, "GameObject") + for name, key in keys.items() + if name not in ("resultBackgroundSprite", "spotScene") + } + kind = record["kind"] + ui_name, field = ("spotWidget", "_afterTalkContents") if kind == "home" else ("resultWidget", "_advSimpleContents") + prefab = roots[ui_name] + cls = "UISpotWidget" if kind == "home" else "UILiveResultWidget" + _, widget = _component(objects, prefab, cls) + overlay = _target(objects, prefab, widget["fields"][field]) + _, layout = _component(objects, overlay["prefab"], "SimpleAdvLayoutRoot", path=overlay["path"]) + opened = [_target(objects, prefab, widget["fields"]["_fadeIn" if kind == "home" else "_inAnimation"])] + home, afterlive = None, None + if kind == "home": + scene = keys.get("spotScene") + scene_roots = [oid for oid in objects.key(scene) if oid in objects and has_json(objects, oid)] if scene else [] + home = { + "spotId": record["spot"]["_id"], + "background": roots["background"], + "situation": roots["situation"], + "scene": scene_roots, + "parts": parts, + "backgroundState": background_state( + objects.json(roots["background"]), objects.json(roots["situation"])["name"] + ), + } + else: + background = roots["resultBackground"] + _, bg = _component(objects, background, "UILiveResultBackgroundWidget") + opened.insert(0, _target(objects, background, bg["fields"]["_inAnimation"])) + ancestors = [ + (i, c) + for i, n in enumerate(objects.json(prefab)["nodes"]) + for c in n["components"] + if component_class(c) == "UISoloLiveMusicResultView" and overlay["path"].startswith(n["path"] + "/") + ] + if len(ancestors) != 1: + raise ValueError("live result overlay needs one UISoloLiveMusicResultView ancestor") + opened.append(_target(objects, prefab, ancestors[0][1]["fields"]["_inAnimation"])) + path = overlay["path"].split("/") + reward = next(("/".join(path[: i + 1]) for i in range(len(path) - 2, 0, -1) if path[i] == "RewardPanel"), None) + if reward is None: + raise ValueError("live result overlay needs a RewardPanel ancestor") + afterlive = { + "backgroundImage": _target(objects, background, bg["fields"]["_backgroundImage"]), + "backgroundSprite": key_object(objects, keys["resultBackgroundSprite"], "Sprite"), + "rewardPanel": {"prefab": prefab, "path": reward}, + "substitutes": list(SUBSTITUTES), + } + graphics = builtin_root(objects, "GraphicsSettings") + included = [ref_id(r) for r in objects.json(graphics).get("m_AlwaysIncludedShaders", ())] + shaders, roles = shader_table(objects, found=[oid for oid in included if oid is not None]) + keep = {oid for oid in objects.ids() if objects.info(oid).get("class") != "MonoScript"} + roles.update(mesh_roles(objects, keep)) + doc = { + "format": FORMAT, + "kind": kind, + "prefabs": roots, + "cameraTarget": roots["cameraTarget"], + "overlayRoot": overlay, + "layoutRoot": {**overlay, "fields": layout["fields"]}, + "openedSequences": opened, + "home": home, + "afterlive": afterlive, + "graphics": {"graphics": graphics}, + "dotween": storyui.script_root(objects, "DOTweenSettings"), + "bindings": animator_bindings(objects), + "shaders": shaders, + } + return doc, keep, roles diff --git a/src/nnnotes/content/storysimpleui.py b/src/nnnotes/content/storysimpleui.py new file mode 100644 index 0000000..805f444 --- /dev/null +++ b/src/nnnotes/content/storysimpleui.py @@ -0,0 +1,75 @@ +"""Localized talk-window and system-message objects for Overlay stories.""" + +from __future__ import annotations + +from ..source import languages +from ..unity import textstyle +from . import storyui +from .livenotes import animator_bindings +from .prefabs import builtin_root, key_object, mesh_roles, mono_json, ref_id, shader_table + +FORMAT = "nnnotes.story-simple-ui/1" +ADDRESSES = { + "talkWindow": "UI/Prefab/Parts/Adv/Talk/UISimpleAdvTalkWindow", + "systemMessage": "UI/Prefab/UISystemMessageWidget", +} + + +def addresses(catalog) -> dict[str, str]: + """The plain catalog address of each prefab, or its embedded address.""" + held = set(catalog) + out = {} + for name, address in ADDRESSES.items(): + found = next((key for key in (address, f"Emb{address}") if key in held), None) + if found is None: + raise ValueError(f"{address}: no plain or embedded prefab in the catalog") + out[name] = found + return out + + +def keys(catalog) -> list[str]: + return sorted({*addresses(catalog).values(), storyui.UI_MANAGER_KEY}) + + +def ui(objects, language: str, settings) -> tuple[dict, set, dict]: + """ui.json with component-id text styles, prefab roots, animation bindings and shader references.""" + code = languages.check(language) + mode = languages.mode(code) + localize = storyui.script_root(objects, "LocalizeManager") + lang = textstyle.language_fonts_json(mono_json(objects, localize)) + styles = storyui._Styles(lang, mode, settings) + roots = {name: key_object(objects, key, "GameObject") for name, key in addresses(objects.keys()).items()} + roots["uiManager"] = key_object(objects, storyui.UI_MANAGER_KEY, "GameObject") + texts = {} + for oid in roots.values(): + texts.update(storyui._texts(objects, oid, styles)) + graphics = builtin_root(objects, "GraphicsSettings") + found = [ref_id(r) for r in objects.json(graphics).get("m_AlwaysIncludedShaders", ())] + shaders, roles = shader_table(objects, found=[oid for oid in found if oid is not None]) + doc = { + "format": FORMAT, + "language": { + "code": code, + "mode": mode, + "field": languages.column(code)[1:], + "lineSpacing": textstyle.LANGUAGE_LINE_SPACING[mode], + "fontNames": lang["languages"][mode]["fontNames"], + "additionalFontNames": lang["languages"][mode]["additionalFontNames"], + "materialTypes": lang["materialTypes"], + "fontSwap": styles.swap, + }, + **roots, + "settings": { + "localizeManager": localize, + "tmpSettings": storyui.script_root(objects, "TMP_Settings"), + "dotween": storyui.script_root(objects, "DOTweenSettings"), + }, + "graphics": {"graphics": graphics}, + "texts": texts, + "textStyle": styles.summary(), + "bindings": animator_bindings(objects), + "shaders": shaders, + } + keep = {oid for oid in objects.ids() if objects.info(oid).get("class") != "MonoScript"} + roles.update(mesh_roles(objects, keep)) + return doc, keep, roles diff --git a/src/nnnotes/pipeline/spot.py b/src/nnnotes/pipeline/spot.py index 38832c1..0028370 100644 --- a/src/nnnotes/pipeline/spot.py +++ b/src/nnnotes/pipeline/spot.py @@ -47,7 +47,7 @@ class RoomStage(ProductStage): """room.gltf: a background key -> room.glb and room.json (module documentation).""" name = ROOM - version = 1 + version = 3 KIND = "SpotRoom" def select(self, keys: list[str], env) -> list[str]: diff --git a/src/nnnotes/pipeline/story.py b/src/nnnotes/pipeline/story.py index 390f1fc..858b0fb 100644 --- a/src/nnnotes/pipeline/story.py +++ b/src/nnnotes/pipeline/story.py @@ -65,7 +65,17 @@ import tempfile from pathlib import Path -from ..content import liveaudio, storyasset, storychat, storyepisode, storyscene, storyui, storyvideo +from ..content import ( + liveaudio, + storyasset, + storychat, + storyepisode, + storyhostproduct, + storyscene, + storysimpleui, + storyui, + storyvideo, +) from ..content.livescene import GRAPHICS from ..content.prefabs import mono_json from ..formats import acb, crilips @@ -80,6 +90,8 @@ from .layout import escape_segment from .live import encode_atoms from .live2d import MODEL, ModelStage +from .spot import ROOM, SPOT, RoomStage, SpotStage +from .storyhost import HOST, HostStage from .textfonts import FontInputsStage from .textfonts import font_atoms as font_atoms from .textfonts import font_settings as _font_settings @@ -105,6 +117,10 @@ "FONTS", "FontsStage", "UiStage", + "SimpleUiStage", + "SIMPLE_UI", + "HOST", + "HostStage", "VideoStage", "cues", "part_products", @@ -113,6 +129,7 @@ SOUND, CRILIPS, SCENE, ASSET = "story.sound", "story.crilips", "story.scene", "story.asset" CHAT, STORY, UI, VIDEO = "story.chat", "story", "story.ui", "story.video" FONTS = "story.fonts" +SIMPLE_UI = "story.simpleui" DEFAULT = "default" SHEET_PREFIX = "Cri/Sound/" LIBRARY = crilips.LIB_ENTRY_SUFFIX @@ -405,6 +422,32 @@ def assemble(self, task, objects) -> Assembly: return Assembly({"ui.json": (contract.encode(doc), "json", UI)}, keep, roles, ui_table) +class SimpleUiStage(UiStage): + """The localized talk window and system-message UI of Overlay stories.""" + + name = SIMPLE_UI + version = 1 + KIND = "StorySimpleUi" + + def ui_keys(self, subject, env): + _language(subject) + return storysimpleui.keys({loc["primaryKey"] for loc in env.fact("index")["locations"]}) + + def keys(self, subject, env): + catalog = {loc["primaryKey"] for loc in env.fact("index")["locations"]} + return sorted({*self.ui_keys(subject, env), *storyui.font_keys(catalog)}) + + def manifest(self, subject): + return f"storysimpleui/{_language(subject)}.json" + + def assemble(self, task, objects): + table = contract.loads(objects.depot.input_bytes(task.input("ui.objects"))) + ui_objects = Objects(table, objects.depot) + lang = textstyle.language_fonts_json(mono_json(ui_objects, storyui.script_root(ui_objects, "LocalizeManager"))) + doc, keep, roles = storysimpleui.ui(ui_objects, task.subject, _font_settings(task, objects, lang)) + return Assembly({"ui.json": (contract.encode(doc), "json", SIMPLE_UI)}, keep, roles, table) + + def font_subject(subject: str) -> tuple[str, str]: episode, sep, language = subject.partition("/") if not sep or str(_adv_id(episode)) != episode: @@ -416,7 +459,7 @@ class FontsStage(UiStage): """An episode and language's font records and glyph pages from its object and font inputs.""" name = FONTS - version = 1 + version = 3 KIND = "StoryFonts" PARAMS = {"fonts": "open"} @@ -437,6 +480,8 @@ def keys(self, subject, env): _, language = font_subject(subject) episode = contract.loads(env.depot.input_bytes(self.episode_input(subject, env))) assets = {r["address"] for r in episode["resources"] if r["kind"] in ("chatwindow", "frame")} + if (episode.get("master") or {}).get("_playbackMode") == 1: + assets.update(storysimpleui.keys({loc["primaryKey"] for loc in env.fact("index")["locations"]})) return sorted({*UiStage().keys(language, env), *assets}) def resources(self, subject, env): @@ -492,9 +537,9 @@ def _adv_id(subject: str) -> int: return int(subject) -def part_paths(parts: dict, adv_id: int) -> dict: +def part_paths(parts: dict, adv_id: int, *, overlay: bool = False, host: dict | None = None) -> dict: """story.json `parts`: the manifest paths of the products of storyepisode.parts `parts`.""" - return { + paths = { "scene": SceneStage().manifest(DEFAULT), "assets": {a: AssetStage().manifest(a) for a in parts["assets"]}, "models": {a: ModelStage().manifest(a) for a in parts["models"]}, @@ -505,6 +550,14 @@ def part_paths(parts: dict, adv_id: int) -> dict: "ui": {lang: UiStage().manifest(lang) for lang in languages.LANGUAGES}, "fonts": {lang: FontsStage().manifest(f"{adv_id}/{lang}") for lang in languages.LANGUAGES}, } + if overlay: + paths["simpleui"] = {lang: SimpleUiStage().manifest(lang) for lang in languages.LANGUAGES} + if host is not None: + paths["host"] = {host["subject"]: HostStage().manifest(host["subject"])} + if host["kind"] == "home": + paths["spots"] = {host["spotKey"]: SpotStage().manifest(host["spotKey"])} + paths["rooms"] = {host["roomKey"]: RoomStage().manifest(host["roomKey"])} + return paths def part_products(story: dict, requested_languages=()) -> dict[str, list[str]]: @@ -521,6 +574,11 @@ def part_products(story: dict, requested_languages=()) -> dict[str, list[str]]: UI: sorted({_language(lang) for lang in requested_languages}), FONTS: sorted({f"{story['advId']}/{_language(lang)}" for lang in requested_languages}), } + if parts.get("simpleui"): + out[SIMPLE_UI] = sorted({_language(lang) for lang in requested_languages}) + out[HOST] = sorted(parts.get("host", {})) + out[SPOT] = sorted(parts.get("spots", {})) + out[ROOM] = sorted(parts.get("rooms", {})) return {name: subjects for name, subjects in out.items() if subjects} @@ -535,6 +593,10 @@ def part_products(story: dict, requested_languages=()) -> dict[str, list[str]]: CRILIPS: CriLipsStage, UI: UiStage, FONTS: FontsStage, + SIMPLE_UI: SimpleUiStage, + HOST: HostStage, + SPOT: SpotStage, + ROOM: RoomStage, } @@ -542,9 +604,9 @@ class StoryStage(ProductStage): """story: an ADV episode -> episode.json and story.json (module documentation).""" name = STORY - version = 2 + version = 4 KIND = "Story" - MASTER = storyepisode.TABLES + MASTER = tuple(sorted({*storyepisode.TABLES, *storyhostproduct.TABLES})) def select(self, keys: list[str], env) -> list[str]: if env.facts.get("master") is None: @@ -555,7 +617,12 @@ def select(self, keys: list[str], env) -> list[str]: def record(self, subject: str, env) -> dict: """The episode's master record (storyepisode.episode_record).""" - return storyepisode.episode_record(master_rows(env, self.MASTER), _adv_id(subject)) + record = storyepisode.episode_record(master_rows(env, storyepisode.TABLES), _adv_id(subject)) + if record["MasterAdv"][0].get("_playbackMode") == storyhostproduct.OVERLAY: + record["host"] = storyhostproduct.episode_host( + master_rows(env, storyhostproduct.TABLES), record["MasterAdv"][0] + ) + return record def keys(self, subject: str, env) -> list[str]: return storyepisode.keys(self.record(subject, env)["MasterAdv"][0]["_advEpisodeAsset"]) @@ -569,7 +636,17 @@ def manifest(self, subject: str) -> str: def assemble(self, task, objects) -> Assembly: record = contract.loads(objects.depot.input_bytes(task.input(MASTER_INPUT))) episode = storyepisode.episode_json(storyepisode.episode(objects, record)) - doc = storyepisode.story(episode, record, part_paths(storyepisode.parts(episode), _adv_id(task.subject))) + if record.get("host") is not None: + episode["host"] = record["host"] + paths = part_paths( + storyepisode.parts(episode), + _adv_id(task.subject), + overlay=record["MasterAdv"][0].get("_playbackMode") == 1, + host=record.get("host"), + ) + doc = storyepisode.story(episode, record, paths) + if record.get("host") is not None: + doc["host"] = record["host"] files = { "episode.json": (contract.encode(episode), "json", "story.episode"), "story.json": (contract.encode(doc), "json", STORY), diff --git a/src/nnnotes/pipeline/storyhost.py b/src/nnnotes/pipeline/storyhost.py new file mode 100644 index 0000000..555ade1 --- /dev/null +++ b/src/nnnotes/pipeline/storyhost.py @@ -0,0 +1,61 @@ +"""Reusable host-screen products for Overlay stories.""" + +from ..content import storyhostproduct +from . import contract +from .assemble import MASTER_INPUT, Assembly, ProductStage, master_input +from .spot import RoomStage, SpotStage +from .views import master_rows + +HOST = "story.host" + + +class HostStage(ProductStage): + name = HOST + version = 1 + KIND = "StoryHost" + MASTER = ("MasterHomeSpot",) + + def record(self, subject, env): + tables = {} if subject == "afterlive" else master_rows(env, self.MASTER) + return storyhostproduct.record(tables, subject) + + def keys(self, subject, env): + held = {loc["primaryKey"] for loc in env.fact("index")["locations"]} + return sorted(storyhostproduct.addresses(self.record(subject, env), held).values()) + + def resources(self, subject, env): + storyhostproduct.parse_subject(subject) + return ["DOTweenSettings"] + + def builtins(self, subject, env): + storyhostproduct.parse_subject(subject) + return ["GraphicsSettings"] + + def parts(self, subject, env): + record = self.record(subject, env) + if record["kind"] == "afterlive": + return {} + row = record["spot"] + return {"spot.files": [row["_situationAssetPath"]], "room.gltf": [row["_backgroundAssetPath"]]} + + def more_inputs(self, subject, env): + record = self.record(subject, env) + parts = self.parts(subject, env) + paths = ( + {} + if not parts + else { + "spot": SpotStage().manifest(parts["spot.files"][0]), + "room": RoomStage().manifest(parts["room.gltf"][0]), + } + ) + return [master_input(env, {"host": record, "parts": paths})] + + def manifest(self, subject): + kind, spot = storyhostproduct.parse_subject(subject) + return f"storyhosts/{kind}{'' if spot is None else '_' + str(spot)}.json" + + def assemble(self, task, objects): + record = contract.loads(objects.depot.input_bytes(task.input(MASTER_INPUT))) + doc, keep, roles = storyhostproduct.host(objects, record["host"], record["parts"]) + return Assembly({"host.json": (contract.encode(doc), "json", HOST)}, keep, roles) diff --git a/src/nnnotes/schemas/story-host.schema.json b/src/nnnotes/schemas/story-host.schema.json new file mode 100644 index 0000000..d39bc06 --- /dev/null +++ b/src/nnnotes/schemas/story-host.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:nnnotes:schema:story-host", + "title": "Overlay story host screen", + "type": "object", + "required": ["format", "kind", "prefabs", "cameraTarget", "overlayRoot", "layoutRoot", "openedSequences", "home", "afterlive", "graphics", "dotween", "bindings", "shaders"], + "properties": { + "format": {"const": "nnnotes.story-host/1"}, + "kind": {"enum": ["home", "afterlive"]}, + "prefabs": {"type": "object", "additionalProperties": {"type": "string"}}, + "cameraTarget": {"type": "string"}, + "overlayRoot": {"$ref": "#/$defs/location"}, + "layoutRoot": {"allOf": [{"$ref": "#/$defs/location"}, {"type": "object", "required": ["fields"], "properties": {"fields": {"type": "object"}}}]}, + "openedSequences": {"type": "array", "items": {"$ref": "#/$defs/location"}}, + "home": {"type": ["object", "null"]}, + "afterlive": {"type": ["object", "null"]}, + "graphics": {"type": "object", "required": ["graphics"], "additionalProperties": {"type": "string"}}, + "dotween": {"type": "string"}, + "bindings": {"type": "object"}, + "shaders": {"type": "object", "additionalProperties": {"type": "string"}} + }, + "$defs": {"location": {"type": "object", "required": ["prefab", "path"], "properties": {"prefab": {"type": "string"}, "path": {"type": "string"}}}} +} diff --git a/src/nnnotes/schemas/story-simple-ui.schema.json b/src/nnnotes/schemas/story-simple-ui.schema.json new file mode 100644 index 0000000..5b8b2ee --- /dev/null +++ b/src/nnnotes/schemas/story-simple-ui.schema.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:nnnotes:schema:story-simple-ui", + "title": "Overlay story talk-window and system-message UI", + "type": "object", + "required": ["format", "language", "talkWindow", "systemMessage", "uiManager", "settings", "graphics", "texts", "textStyle", "bindings", "shaders"], + "properties": { + "format": {"const": "nnnotes.story-simple-ui/1"}, + "language": {"type": "object", "required": ["code", "mode", "field", "lineSpacing", "fontNames", "fontSwap"], "properties": {"code": {"enum": ["ja", "en", "zh-Hant", "zh-Hans", "ko"]}}}, + "talkWindow": {"type": "string"}, + "systemMessage": {"type": "string"}, + "uiManager": {"type": "string"}, + "settings": {"type": "object", "required": ["localizeManager", "tmpSettings", "dotween"], "additionalProperties": {"type": "string"}}, + "graphics": {"type": "object", "required": ["graphics"], "additionalProperties": {"type": "string"}}, + "texts": {"type": "object", "additionalProperties": {"type": "object", "required": ["style", "localized"], "properties": {"style": {"type": "object"}, "localized": {"type": ["object", "null"]}}}}, + "textStyle": {"type": "object"}, + "bindings": {"type": "object"}, + "shaders": {"type": "object", "additionalProperties": {"type": "string"}} + } +} diff --git a/tests/content/test_room.py b/tests/content/test_room.py index f57a36f..3331bf6 100644 --- a/tests/content/test_room.py +++ b/tests/content/test_room.py @@ -177,6 +177,12 @@ def test_room_bakes_the_drawn_meshes_and_lists_what_draws_nothing(tmp_path): "nullMaterialSubmeshes": [{"mesh": "quad2", "path": "room/floor", "submesh": 1}], "nullMeshFilters": ["room/nomesh"], "emptyMeshes": [{"mesh": "empty_mesh", "path": "room/empty"}, {"mesh": "points", "path": "room/points"}], + "nodes": [ + {"mesh": "CAB-r:10", "path": "room/wall"}, + {"mesh": "CAB-r:11", "path": "room/floor"}, + {"mesh": "CAB-r:10", "path": "room/wall2"}, + ], + "materialIds": ["CAB-r:20", "CAB-r:21"], **{k: doc[k] for k in ("materials", "textures", "samplers")}, } assert gdoc["asset"]["extras"] == {"backgroundKey": so.BACKGROUND} diff --git a/tests/content/test_storyhostproduct.py b/tests/content/test_storyhostproduct.py new file mode 100644 index 0000000..bdf6ce8 --- /dev/null +++ b/tests/content/test_storyhostproduct.py @@ -0,0 +1,235 @@ +"""Overlay host selection and scene state from synthetic master rows and prefabs.""" + +import pytest +from jsonschema import Draft202012Validator + +from nnnotes import schemas +from nnnotes.content import storyhostproduct as hp +from nnnotes.pipeline import contract, story +from nnnotes.pipeline.depot import Depot +from nnnotes.pipeline.storyhost import HostStage +from tests.helpers import storyui as sui +from tests.helpers.livenotes import at, comp, node +from tests.pipeline.test_live import run, stored + +HOME = { + "_id": 7, + "_advId": 3, + "_advNameTextId": "area", + "_backgroundAssetPath": "Spot/room/Background/room", + "_situationAssetPath": "Spot/room/day/day", +} + + +def tables(): + return { + "MasterHomeSpot": [HOME], + "MasterStoryHomeSpotTapTalkEpisode": [{"_id": 11, "_advId": 2, "_spotId": 7, "_characterId": 5}], + "MasterStoryLiveResultEpisode": [{"_id": 12, "_advId": 4}], + "MasterText": [{"_id": "area", "_japanese": "A", "_traditionalChinese": "B"}], + } + + +def test_episode_host_selects_home_area_tap_or_result(): + t = tables() + assert hp.episode_host({}, {"_id": 1, "_playbackMode": 0}) is None + tap = hp.episode_host(t, {"_id": 2, "_playbackMode": 1}) + assert tap["subject"] == "home/7" and tap["talk"] == "tap" and tap["characterId"] == 5 + area = hp.episode_host(t, {"_id": 3, "_playbackMode": 1}) + assert area["title"]["zh-Hant"] == "B" and area["talk"] == "area" + assert area["spotKey"] == HOME["_situationAssetPath"] and area["roomKey"] == HOME["_backgroundAssetPath"] + assert hp.episode_host(t, {"_id": 4, "_playbackMode": 1})["subject"] == "afterlive" + with pytest.raises(ValueError, match="found 0"): + hp.episode_host(t, {"_id": 5, "_playbackMode": 1}) + t["MasterStoryLiveResultEpisode"].append({"_id": 13, "_advId": 2}) + with pytest.raises(ValueError, match="found 2"): + hp.episode_host(t, {"_id": 2, "_playbackMode": 1}) + + +def background(): + rows = [ + node( + "room", + comp( + "MonoBehaviour", + 2, + "App|App.Spot|SpotBackground", + _situationObjects=[ + {"SituationName": "day", "HideObjects": [{"gameObject": "room/content/wall"}, None]}, + {"SituationName": "day", "HideObjects": [{"gameObject": "room/content/floor"}]}, + ], + ), + ), + node("room/content"), + node("room/content/floor", comp("MeshRenderer", 3)), + node("room/content/wall", comp("MeshRenderer", 4)), + ] + rows[2]["active"] = False + return {"name": "room", "nodes": rows} + + +def test_background_activates_children_hides_first_match_and_sets_floor_queue(): + doc = background() + state = hp.background_state(doc, "day(Clone)") + assert state["situation"] == { + "name": "day", + "matched": True, + "activate": ["room/content/floor", "room/content/wall"], + "hide": ["room/content/wall", None], + } + assert state["nodes"]["room/content/floor"] == {"active": True, "renderQueue": 2000} + assert state["nodes"]["room/content/wall"] == {"active": False, "renderQueue": None} + assert not doc["nodes"][2]["active"] + unmatched = hp.background_state(doc, "night") + assert not unmatched["nodes"]["room/content/floor"]["active"] + assert unmatched["floor"]["renderer"] is None + + +def test_floor_queue_is_selected_before_the_background_root_is_activated(): + doc = background() + doc["nodes"][0]["active"] = False + state = hp.background_state(doc, "day") + assert state["nodes"]["room/content/floor"]["active"] + assert state["nodes"]["room/content/floor"]["renderQueue"] is None + + +def host_table(depot, kind): + objs = sui.objects() + roots = {} + + def add(name, doc, cls="GameObject"): + oid = f"CAB-host:{10 + len(roots)}" + roots[name] = oid + objs[oid] = (cls, name, {"json": doc}) + return oid + + add("cameraTarget", {"name": "CameraTargetRenderer", "nodes": [node("CameraTargetRenderer")]}) + if kind == "home": + add( + "spotWidget", + { + "name": "UISpotWidget", + "nodes": [ + node( + "UISpotWidget", + comp( + "MonoBehaviour", + 31, + "App|App.Spot|UISpotWidget", + _afterTalkContents={"gameObject": "UISpotWidget/talk"}, + _fadeIn=at("UISpotWidget/open"), + ), + ), + node( + "UISpotWidget/talk", comp("MonoBehaviour", 32, "App|App.Adv|SimpleAdvLayoutRoot", _slotCount=2) + ), + node("UISpotWidget/open", comp("Animator", 33, m_Controller=None)), + ], + }, + ) + add("spotLetterbox", {"name": "UISpotLetterbox", "nodes": [node("UISpotLetterbox")]}) + add("background", background()) + add("situation", {"name": "day", "nodes": [node("day")]}) + record = hp.record(tables(), "home/7") + else: + add( + "resultWidget", + { + "name": "UILiveResultWidget", + "nodes": [ + node( + "Result", + comp( + "MonoBehaviour", + 31, + "App|App.Live|UILiveResultWidget", + _advSimpleContents={"gameObject": "Result/View/RewardPanel/Talk"}, + _inAnimation=at("Result/open"), + ), + ), + node( + "Result/View", + comp( + "MonoBehaviour", + 32, + "App|App.Live|UISoloLiveMusicResultView", + _inAnimation=at("Result/open"), + ), + ), + node("Result/View/RewardPanel"), + node( + "Result/View/RewardPanel/Talk", + comp("MonoBehaviour", 33, "App|App.Adv|SimpleAdvLayoutRoot", _slotCount=2), + ), + node("Result/open", comp("Animator", 34, m_Controller=None)), + ], + }, + ) + add( + "resultBackground", + { + "name": "Background", + "nodes": [ + node( + "Background", + comp( + "MonoBehaviour", + 41, + "App|App.Live|UILiveResultBackgroundWidget", + _inAnimation=at("Background/open"), + _backgroundImage=at("Background/image", "Image"), + ), + ), + node("Background/open", comp("Animator", 42, m_Controller=None)), + node("Background/image", comp("Image", 43, m_Sprite=None)), + ], + }, + ) + add("resultBackgroundSprite", {}, "Sprite") + record = hp.record({}, "afterlive") + keys = {"Emb" + hp.ADDRESSES[name]: [oid] for name, oid in roots.items() if name in hp.ADDRESSES} + if kind == "home": + keys.update( + {HOME["_backgroundAssetPath"]: [roots["background"]], HOME["_situationAssetPath"]: [roots["situation"]]} + ) + return sui.table_doc(depot, objs=objs, keys=keys), record, roots + + +@pytest.mark.parametrize("kind", ["home", "afterlive"]) +def test_host_product_keeps_hierarchies_and_initial_state(tmp_path, kind): + depot = Depot(tmp_path / "d") + table, record, roots = host_table(depot, kind) + inputs = [ + stored(depot, contract.encode(table), "objects"), + stored( + depot, + contract.encode({"host": record, "parts": {"room": "rooms/room.json", "spot": "spots/day.json"}}), + "master", + ), + ] + subject = "home/7" if kind == "home" else "afterlive" + _, _, arts = run(depot, HostStage(), subject, inputs) + doc = contract.loads(depot.read(arts["host.json"]["content"]["sha256"])) + Draft202012Validator(schemas.load("story-host")).validate(doc) + assert doc["kind"] == kind and doc["cameraTarget"] == roots["cameraTarget"] + assert doc["layoutRoot"]["fields"]["_slotCount"] == 2 + assert len(doc["openedSequences"]) == (1 if kind == "home" else 3) + if kind == "home": + assert doc["home"]["backgroundState"]["floor"]["renderer"] == "room/content/floor" + assert doc["home"]["parts"]["room"] == "rooms/room.json" + else: + assert doc["afterlive"]["backgroundSprite"] == roots["resultBackgroundSprite"] + assert doc["afterlive"]["rewardPanel"]["path"] == "Result/View/RewardPanel" + kept = contract.loads(depot.read(arts["objects"]["content"]["sha256"]))["objects"] + assert set(roots.values()) <= set(kept) + assert sui.SCRIPT not in kept + + +def test_home_story_requests_host_room_and_spot_products(): + host = hp.episode_host(tables(), {"_id": 3, "_playbackMode": 1}) + parts = {"assets": [], "models": [], "sounds": [], "videos": [], "chat": False, "crilips": False} + paths = story.part_paths(parts, 3, overlay=True, host=host) + requested = story.part_products({"advId": 3, "parts": paths}, ["ja"]) + assert requested["story.host"] == ["home/7"] + assert requested["room.gltf"] == [HOME["_backgroundAssetPath"]] + assert requested["spot.files"] == [HOME["_situationAssetPath"]] diff --git a/tests/content/test_storysimpleui.py b/tests/content/test_storysimpleui.py new file mode 100644 index 0000000..96218ec --- /dev/null +++ b/tests/content/test_storysimpleui.py @@ -0,0 +1,91 @@ +"""Overlay UI products and their episode font bindings on synthetic objects.""" + +import copy + +import pytest +from jsonschema import Draft202012Validator + +from nnnotes import schemas +from nnnotes.content import storysimpleui, storyui +from nnnotes.pipeline import contract, story +from nnnotes.pipeline.assemble import Objects +from nnnotes.pipeline.depot import Depot +from nnnotes.pipeline.stages import Env +from tests.content.test_storyfontproduct import fixture +from tests.helpers import storyui as sui +from tests.helpers.fonts import make_font +from tests.pipeline.test_live import run, stored + + +def simple_keys(): + return { + "Emb" + storysimpleui.ADDRESSES["talkWindow"]: [sui.WIDGET], + "Emb" + storysimpleui.ADDRESSES["systemMessage"]: [sui.PLAIN], + storyui.UI_MANAGER_KEY: [sui.UI_MANAGER], + } + + +def test_plain_and_embedded_addresses(): + assert storysimpleui.addresses(simple_keys()) == {k: "Emb" + v for k, v in storysimpleui.ADDRESSES.items()} + held = {*simple_keys(), storysimpleui.ADDRESSES["talkWindow"]} + assert storysimpleui.addresses(held)["talkWindow"] == storysimpleui.ADDRESSES["talkWindow"] + with pytest.raises(ValueError, match="no plain or embedded"): + storysimpleui.keys([]) + + +def test_simple_ui_localizes_text_and_retains_prefab_references(tmp_path): + depot = Depot(tmp_path / "d") + table = sui.table_doc(depot, keys=simple_keys()) + doc, keep, _ = storysimpleui.ui(Objects(table, depot), "zh-Hant", sui.font_settings()) + Draft202012Validator(schemas.load("story-simple-ui")).validate(doc) + assert doc["talkWindow"] == sui.WIDGET and doc["systemMessage"] == sui.PLAIN + assert doc["texts"]["CAB-ui:101"]["localized"]["fontAsset"] == "MainT SDF" + assert doc["texts"]["CAB-ui:111"]["localized"] is None + assert sui.WIDGET in keep and sui.SCRIPT not in keep and sui.FONT_JA not in keep + assert doc["language"]["field"] == "traditionalChinese" + + +@pytest.mark.parametrize("source", ["open", "game"]) +def test_overlay_font_product_includes_simple_ui_texts(tmp_path, source): + depot = Depot(tmp_path / "d") + inputs, table, episode = fixture(depot) + table["keys"].update(simple_keys()) + episode["master"] = {"_playbackMode": 1} + # A distinct system-message text makes omission from the font product observable. + plain = copy.deepcopy(Objects(table, depot).json(sui.PLAIN)) + plain["nodes"][0]["components"][0]["pathId"] = "711" + body = contract.encode(plain) + table["objects"]["CAB-simple:1"] = copy.deepcopy(table["objects"][sui.PLAIN]) + table["objects"]["CAB-simple:1"]["files"]["json"].update(sha256=depot.put(body), size=len(body)) + table["keys"]["Emb" + storysimpleui.ADDRESSES["systemMessage"]] = ["CAB-simple:1"] + inputs = [i for i in inputs if i.role not in ("objects", "episode")] + inputs += [stored(depot, contract.encode(table), "objects"), stored(depot, contract.encode(episode), "episode")] + if source == "open": + font = make_font(tmp_path / "font.ttf", underscore=True).read_bytes() + inputs += [stored(depot, font, f"font:{code}") for code in ("ja", "zh-Hant")] + _, _, artifacts = run(depot, story.FontsStage(), "1/zh-Hant", inputs, {"fonts": source}, story.font_atoms()) + doc = contract.loads(depot.read(artifacts["fonts.json"]["content"]["sha256"])) + assert "CAB-simple:711" in doc["texts"] + binding = doc["texts"]["CAB-simple:711"]["font"] + assert binding["fontAsset"] in doc["fonts"] and binding["material"] in doc["materials"] + + +def test_overlay_parts_request_simple_ui_for_selected_languages(): + parts = {"assets": [], "models": [], "sounds": [], "videos": [], "chat": False, "crilips": False} + normal = story.part_paths(parts, 1) + overlay = story.part_paths(parts, 1, overlay=True) + assert "simpleui" not in normal + assert overlay["simpleui"]["ja"] == "storysimpleui/ja.json" + products = story.part_products({"advId": 1, "parts": overlay}, ["ja", "ja", "zh-Hant"]) + assert products[story.SIMPLE_UI] == ["ja", "zh-Hant"] + + +def test_font_roots_include_simple_ui_for_overlay_episodes(tmp_path): + depot = Depot(tmp_path / "d") + episode = {"master": {"_playbackMode": 1}, "resources": []} + index = {"locations": [{"primaryKey": k} for k in {*sui.catalog_keys(), *simple_keys()}]} + env = Env( + depot, {"index": index, "product_inputs": {"episode:1": stored(depot, contract.encode(episode), "episode:1")}} + ) + assert set(simple_keys()) <= set(story.FontsStage().keys("1/ja", env)) + assert story.SimpleUiStage().manifest("ja") == "storysimpleui/ja.json"