diff --git a/client/src/dolls/README.md b/client/src/dolls/README.md
index e2bb7c62..8e43c74a 100644
--- a/client/src/dolls/README.md
+++ b/client/src/dolls/README.md
@@ -20,7 +20,14 @@ const preview = await makeDoll(scene, x, y, 'draft', await flatDoll(appearance),
a job painted. `isFigure` tells the two apart.
- **`makeFigure(scene, x, y, id, figure, height?)`** draws a `SpriteFigure` once its atlas has
loaded. Its origin is on the ground under the middle of its head, and `height` is how many
- pixels the standing figure is.
+ pixels the standing figure is. **`wear(figure)`** draws another figure the same size once its
+ atlas has loaded.
+- **The player with bionics** (`bionics.ts`): `playerFigureId(body)` is the player's figure for
+ what a `Body` has fitted, `player` with nothing and `player_legs_datajack_eye` with everything.
+ The eye is painted where it shows, so either socket is the same figure. `PlayerBody` keeps the
+ last body and says when its figure changes: `world/assets.ts` feeds one from `player_state`,
+ `dollFor('player')` picks the player's figure by it, and the side view's player wears the new
+ figure when a bionic is fitted.
- **`SpriteFigure.setPose(pose, facing, phase, options)`** is `PaperDoll`'s: `pose` is the sim's
`Pose`, or the player's `'jumping'` or `'landing'`, and `phase` how far through its cycle it is,
any number. Each pose plays its painted frames by phase (`sprite.ts`):
diff --git a/client/src/dolls/assets.test.ts b/client/src/dolls/assets.test.ts
index 9b5b6d83..7e5585ca 100644
--- a/client/src/dolls/assets.test.ts
+++ b/client/src/dolls/assets.test.ts
@@ -22,6 +22,13 @@ describe('the committed figures', () => {
'mirela',
'odalys',
'player',
+ 'player_datajack',
+ 'player_datajack_eye',
+ 'player_eye',
+ 'player_legs',
+ 'player_legs_datajack',
+ 'player_legs_datajack_eye',
+ 'player_legs_eye',
'priya',
'raymond',
'tobias',
diff --git a/client/src/dolls/bionics.test.ts b/client/src/dolls/bionics.test.ts
new file mode 100644
index 00000000..6c62b17b
--- /dev/null
+++ b/client/src/dolls/bionics.test.ts
@@ -0,0 +1,71 @@
+import { describe, expect, it, vi } from 'vitest';
+import type { Body } from '../net/protocol';
+import { dolls } from './assets';
+import { FITTED, isPlayerFigure, PlayerBody, playerFigureId } from './bionics';
+
+const whole: Body = {
+ legs: 'present',
+ vertebra: 'present',
+ left_eye: 'present',
+ right_eye: 'present',
+ left_kidney: 'present',
+ right_kidney: 'present',
+};
+
+describe('the player’s figure', () => {
+ it.each<[Partial
, string]>([
+ [{}, 'player'],
+ [{ left_kidney: 'sold', right_eye: 'sold' }, 'player'],
+ [{ legs: 'hydraulic_legs' }, 'player_legs'],
+ [{ vertebra: 'datajack' }, 'player_datajack'],
+ [{ left_eye: 'infrared_eye' }, 'player_eye'],
+ [{ right_eye: 'infrared_eye', legs: 'hydraulic_legs' }, 'player_legs_eye'],
+ [{ right_eye: 'infrared_eye', vertebra: 'datajack', legs: 'hydraulic_legs' }, 'player_legs_datajack_eye'],
+ ])('with %o fitted is %s', (fitted, id) => {
+ expect(playerFigureId({ ...whole, ...fitted })).toBe(id);
+ });
+
+ it('is the whole player before the body is known', () => {
+ expect(playerFigureId()).toBe('player');
+ });
+
+ it('is committed for every combination of bionics, with the same frames and poses', () => {
+ const combinations = FITTED.reduce((all, part) => [...all, ...all.map((c) => [...c, part])], [[]]);
+ for (const parts of combinations) {
+ const body: Body = { ...whole };
+ if (parts.includes('hydraulic_legs')) body.legs = 'hydraulic_legs';
+ if (parts.includes('datajack')) body.vertebra = 'datajack';
+ if (parts.includes('infrared_eye')) body.left_eye = 'infrared_eye';
+ const figure = dolls[playerFigureId(body)];
+ expect(figure, playerFigureId(body)).toBeDefined();
+ expect(Object.keys(figure.frames).sort()).toEqual(Object.keys(dolls.player.frames).sort());
+ expect(figure.poses).toEqual(dolls.player.poses);
+ expect(figure.height).toBe(dolls.player.height);
+ }
+ });
+
+ it('tells the player’s figures from the residents’', () => {
+ expect(isPlayerFigure('player')).toBe(true);
+ expect(isPlayerFigure('player_legs_eye')).toBe(true);
+ expect(isPlayerFigure('priya')).toBe(false);
+ expect(isPlayerFigure(undefined)).toBe(false);
+ });
+});
+
+describe('PlayerBody', () => {
+ it('says when the figure changes, and only then', () => {
+ const body = new PlayerBody();
+ const heard = vi.fn();
+ const stop = body.onChange(heard);
+
+ body.set(whole);
+ body.set({ ...whole, left_kidney: 'sold' });
+ body.set({ ...whole, legs: 'hydraulic_legs' });
+ body.set({ ...whole, legs: 'hydraulic_legs', right_eye: 'infrared_eye' });
+ stop();
+ body.set(whole);
+
+ expect(heard.mock.calls).toEqual([['player_legs'], ['player_legs_eye']]);
+ expect(body.figure).toBe('player');
+ });
+});
diff --git a/client/src/dolls/bionics.ts b/client/src/dolls/bionics.ts
new file mode 100644
index 00000000..c1e98bf9
--- /dev/null
+++ b/client/src/dolls/bionics.ts
@@ -0,0 +1,41 @@
+import type { Bionic, Body } from '../net/protocol';
+
+export const PLAYER_FIGURE = 'player';
+/** In the order a figure's id names them. */
+export const FITTED: readonly Bionic[] = ['hydraulic_legs', 'datajack', 'infrared_eye'];
+const NAMES: Record = { hydraulic_legs: 'legs', datajack: 'datajack', infrared_eye: 'eye' };
+
+/** The bionics a body has fitted, in `FITTED`'s order. */
+export function fitted(body?: Body): Bionic[] {
+ const parts = new Set(body ? Object.values(body) : []);
+ return FITTED.filter((bionic) => parts.has(bionic));
+}
+
+/** The player's figure for a body: `player` with nothing fitted, `player_legs_eye` and so on. */
+export const playerFigureId = (body?: Body): string =>
+ [PLAYER_FIGURE, ...fitted(body).map((bionic) => NAMES[bionic])].join('_');
+
+export const isPlayerFigure = (id?: string): boolean =>
+ id === PLAYER_FIGURE || !!id?.startsWith(`${PLAYER_FIGURE}_`);
+
+/** The player's body as last heard, and who to tell when what's fitted changes. */
+export class PlayerBody {
+ private body?: Body;
+ private readonly listeners = new Set<(figure: string) => void>();
+
+ get figure(): string {
+ return playerFigureId(this.body);
+ }
+
+ set(body: Body): void {
+ const before = this.figure;
+ this.body = body;
+ if (this.figure !== before) this.listeners.forEach((listener) => listener(this.figure));
+ }
+
+ /** Calls `listener` with the new figure's id whenever it changes. */
+ onChange(listener: (figure: string) => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+}
diff --git a/client/src/dolls/phaser.fixture.ts b/client/src/dolls/phaser.fixture.ts
new file mode 100644
index 00000000..894b34e1
--- /dev/null
+++ b/client/src/dolls/phaser.fixture.ts
@@ -0,0 +1,113 @@
+// What a SpriteFigure uses of Phaser, for tests in Node: vi.mock('phaser', () => import('./phaser.fixture')).
+import type Phaser from 'phaser';
+
+export class FakeImage {
+ key: string;
+ frame?: string;
+ flipX = false;
+ tint = -1;
+
+ constructor(_scene: unknown, _x: number, _y: number, key: string, frame?: string) {
+ this.key = key;
+ this.frame = frame;
+ }
+
+ setTexture(key: string, frame?: string): this {
+ this.key = key;
+ this.frame = frame;
+ return this;
+ }
+
+ setFlipX(flip: boolean): this {
+ this.flipX = flip;
+ return this;
+ }
+
+ setTint(tint: number): this {
+ this.tint = tint;
+ return this;
+ }
+
+ setOrigin(): this {
+ return this;
+ }
+
+ setPosition(): this {
+ return this;
+ }
+
+ setAngle(): this {
+ return this;
+ }
+
+ setScale(): this {
+ return this;
+ }
+}
+
+class FakeContainer {
+ scene?: unknown;
+ readonly list: FakeImage[] = [];
+ scale = 1;
+ private readonly destroyed: (() => void)[] = [];
+
+ constructor(scene: unknown) {
+ this.scene = scene;
+ }
+
+ add(child: FakeImage): this {
+ this.list.push(child);
+ return this;
+ }
+
+ setScale(scale: number): this {
+ this.scale = scale;
+ return this;
+ }
+
+ setPosition(): this {
+ return this;
+ }
+
+ once(_event: string, listener: () => void): this {
+ this.destroyed.push(listener);
+ return this;
+ }
+
+ destroy(): void {
+ this.destroyed.forEach((listener) => listener());
+ this.scene = undefined;
+ }
+}
+
+export default {
+ GameObjects: { Container: FakeContainer, Image: FakeImage, Events: { DESTROY: 'destroy' } },
+ Textures: { FilterMode: { LINEAR: 0 } },
+};
+
+/** A scene whose textures are the atlases `loaded` and any added since. */
+export function fakeScene(loaded: string[] = []): Phaser.Scene {
+ const keys = new Set(loaded);
+ const textures = {
+ exists: (key: string) => keys.has(key),
+ addImage: (key: string) => (keys.add(key), { setFilter: () => undefined, add: () => undefined }),
+ };
+ return { textures } as unknown as Phaser.Scene;
+}
+
+/** The image a figure draws. */
+export const drawnImage = (figure: object): FakeImage => (figure as { list: FakeImage[] }).list[0];
+
+/** Browser images whose loads the test finishes, in any order, by calling `onload`. */
+export function loadingImages(): { onload: () => void }[] {
+ const started: { onload: () => void }[] = [];
+ class LoadingImage {
+ onload = () => undefined;
+ onerror = () => undefined;
+ set src(_url: string) {
+ started.push(this);
+ }
+ }
+ globalThis.Image = LoadingImage as unknown as typeof Image;
+ return started;
+}
diff --git a/client/src/dolls/sprite-figure.test.ts b/client/src/dolls/sprite-figure.test.ts
new file mode 100644
index 00000000..23f75310
--- /dev/null
+++ b/client/src/dolls/sprite-figure.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it, vi } from 'vitest';
+import { dolls } from './assets';
+import { drawnImage, fakeScene, loadingImages } from './phaser.fixture';
+import { shown } from './sprite';
+import { SpriteFigure } from './sprite-figure';
+
+vi.mock('phaser', () => import('./phaser.fixture'));
+
+const atlases = Object.keys(dolls).map((id) => `figure-${id}`);
+
+describe('wearing another figure', () => {
+ it('draws it the same size, in the pose it was in, once its atlas has loaded', async () => {
+ const figure = new SpriteFigure(fakeScene(atlases), 0, 0, dolls.player, 'figure-player', 245);
+ figure.setPose('walking', 'west', 0.3);
+
+ await figure.wear(dolls.player_legs);
+
+ expect(figure.figure).toBe(dolls.player_legs);
+ const image = drawnImage(figure);
+ expect(image.key).toBe('figure-player_legs');
+ expect(image.frame).toBe(shown(dolls.player_legs, 'walking', 'west', 0.3).frame);
+ expect(image.flipX).toBe(true);
+ expect((figure as unknown as { scale: number }).scale).toBeCloseTo(245 / dolls.player_legs.height);
+ });
+
+ it('wears the last of several asked for while their atlases load, whichever loads last', async () => {
+ const loads = loadingImages();
+ const figure = new SpriteFigure(fakeScene(['figure-player']), 0, 0, dolls.player, 'figure-player');
+
+ const legs = figure.wear(dolls.player_legs);
+ const eye = figure.wear(dolls.player_eye);
+ loads[1].onload();
+ await eye;
+ loads[0].onload();
+ await legs;
+
+ expect(figure.figure).toBe(dolls.player_eye);
+ expect(drawnImage(figure).key).toBe('figure-player_eye');
+ });
+
+ it('keeps its figure if it was destroyed while the atlas loaded', async () => {
+ const loads = loadingImages();
+ const scene = fakeScene(['figure-player']);
+ const figure = new SpriteFigure(scene, 0, 0, dolls.player, 'figure-player');
+
+ const legs = figure.wear(dolls.player_legs);
+ figure.destroy();
+ loads[0].onload();
+ await legs;
+
+ expect(figure.figure).toBe(dolls.player);
+ });
+});
diff --git a/client/src/dolls/sprite-figure.ts b/client/src/dolls/sprite-figure.ts
index a4c57507..b98cef67 100644
--- a/client/src/dolls/sprite-figure.ts
+++ b/client/src/dolls/sprite-figure.ts
@@ -27,12 +27,13 @@ export function addFigureAtlas(scene: Phaser.Scene, key: string, figure: FigureM
/** A resident painted whole, its origin on the ground under the middle of its head. `setPose`
* is `PaperDoll`'s: each pose shows its painted frames by phase, mirrored to face west. */
export class SpriteFigure extends Phaser.GameObjects.Container {
- readonly figure: FigureManifest;
+ private worn: FigureManifest;
private readonly image: Phaser.GameObjects.Image;
private key: string;
private size = 1;
private posedAs?: Parameters;
private drawn?: Shown;
+ private asked?: FigureManifest;
constructor(
scene: Phaser.Scene,
@@ -43,7 +44,7 @@ export class SpriteFigure extends Phaser.GameObjects.Container {
height = figure.height,
) {
super(scene, x, y);
- this.figure = figure;
+ this.worn = figure;
this.key = key;
this.image = new Phaser.GameObjects.Image(scene, 0, 0, key, figure.poses.stand[0]);
this.add(this.image);
@@ -51,6 +52,10 @@ export class SpriteFigure extends Phaser.GameObjects.Container {
this.setPose('standing', 'east');
}
+ get figure(): FigureManifest {
+ return this.worn;
+ }
+
/** Scales the figure so it stands `height` pixels tall. */
setDollHeight(height: number): this {
this.size = height / this.figure.height;
@@ -66,6 +71,17 @@ export class SpriteFigure extends Phaser.GameObjects.Container {
return this;
}
+ /** Draws another figure the same size once its atlas has loaded; the last one asked for wins. */
+ async wear(figure: FigureManifest, key = `figure-${figure.id}`): Promise {
+ this.asked = figure;
+ await addFigureAtlas(this.scene, key, figure);
+ if (this.asked !== figure || !this.scene) return;
+ const height = this.size * this.worn.height;
+ this.worn = figure;
+ this.setDollHeight(height);
+ this.paint(key);
+ }
+
setPose(pose: DollPose, facing: Facing, phase = 0, options: PoseOptions = {}): this {
this.posedAs = [pose, facing, phase, options];
return this.draw(shown(this.figure, pose, facing, phase, options));
diff --git a/client/src/side/poses.ts b/client/src/side/poses.ts
index 986d4257..49038f98 100644
--- a/client/src/side/poses.ts
+++ b/client/src/side/poses.ts
@@ -25,7 +25,7 @@ const AS: Record = {
/** Allocates nothing once each step of a pose has been drawn. */
export class PoseCache {
- private readonly figure: FigureManifest;
+ readonly figure: FigureManifest;
private readonly shown: (Shown | undefined)[] = [];
private readonly options: PoseOptions = {};
diff --git a/client/src/side/side-doll.test.ts b/client/src/side/side-doll.test.ts
new file mode 100644
index 00000000..aefc2507
--- /dev/null
+++ b/client/src/side/side-doll.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { drawnImage, fakeScene } from '../dolls/phaser.fixture';
+import type { Body } from '../net';
+import { dolls, playerBody } from '../world/assets';
+import { SideDoll } from './side-doll';
+
+vi.mock('phaser', () => import('../dolls/phaser.fixture'));
+
+const whole: Body = {
+ legs: 'present',
+ vertebra: 'present',
+ left_eye: 'present',
+ right_eye: 'present',
+ left_kidney: 'present',
+ right_kidney: 'present',
+};
+const scene = () => fakeScene(Object.keys(dolls).map((id) => `figure-${id}`));
+
+afterEach(() => playerBody.set(whole));
+
+describe('the side view’s player', () => {
+ it('wears the figure for each bionic fitted', async () => {
+ const doll = new SideDoll(scene(), dolls.player, 'figure-player');
+
+ playerBody.set({ ...whole, legs: 'hydraulic_legs' });
+ await vi.waitFor(() => expect(doll.figure).toBe(dolls.player_legs));
+ playerBody.set({ ...whole, legs: 'hydraulic_legs', left_eye: 'infrared_eye' });
+
+ await vi.waitFor(() => expect(doll.figure).toBe(dolls.player_legs_eye));
+ expect(drawnImage(doll).key).toBe('figure-player_legs_eye');
+ });
+
+ it('catches up with a bionic fitted while its first atlas loaded', async () => {
+ const picked = dolls.player;
+ playerBody.set({ ...whole, vertebra: 'datajack' });
+
+ const doll = new SideDoll(scene(), picked, 'figure-player');
+
+ await vi.waitFor(() => expect(doll.figure).toBe(dolls.player_datajack));
+ });
+
+ it('stops following once it’s gone', async () => {
+ const doll = new SideDoll(scene(), dolls.player, 'figure-player');
+ doll.destroy();
+
+ playerBody.set({ ...whole, legs: 'hydraulic_legs' });
+ await Promise.resolve();
+
+ expect(doll.figure).toBe(dolls.player);
+ });
+
+ it('leaves a resident’s figure alone', async () => {
+ const doll = new SideDoll(scene(), dolls.kofi, 'figure-kofi');
+
+ playerBody.set({ ...whole, legs: 'hydraulic_legs' });
+ await Promise.resolve();
+
+ expect(doll.figure).toBe(dolls.kofi);
+ });
+});
diff --git a/client/src/side/side-doll.ts b/client/src/side/side-doll.ts
index 60273f78..b8fb0f01 100644
--- a/client/src/side/side-doll.ts
+++ b/client/src/side/side-doll.ts
@@ -1,26 +1,38 @@
-import type Phaser from 'phaser';
+import Phaser from 'phaser';
+import { isPlayerFigure, PLAYER_FIGURE } from '../dolls/bionics';
import type { FigureManifest } from '../dolls/manifest';
import type { Shown } from '../dolls/sprite';
import { SpriteFigure } from '../dolls/sprite-figure';
+import { dollFor, playerBody } from '../world/assets';
import { DOLL_M, PPM } from './layout';
import type { Drawn } from './motion';
import { PoseCache } from './poses';
/** A resident's painted figure, posed from a cache and tinted by the light; its origin is on the
- * ground under the middle of its head. */
+ * ground under the middle of its head. The player's changes as bionics are fitted. */
export class SideDoll extends SpriteFigure {
- private readonly cache: PoseCache;
+ private cache: PoseCache;
private tint = -1;
private onSeat?: { from: Shown; seat: number; shown: Shown };
constructor(scene: Phaser.Scene, figure: FigureManifest, key: string, height = DOLL_M * PPM) {
super(scene, 0, 0, figure, key, height);
this.cache = new PoseCache(figure);
+ if (isPlayerFigure(figure.id)) {
+ this.once(Phaser.GameObjects.Events.DESTROY, playerBody.onChange(() => this.follow()));
+ if (dollFor(PLAYER_FIGURE) !== figure) this.follow();
+ }
+ }
+
+ /** Wears the player's figure for what's fitted now. */
+ private follow(): void {
+ this.wear(dollFor(PLAYER_FIGURE)).catch((error: unknown) => console.warn(error));
}
/** Does nothing unless its frame, facing, seat or tint changed. `seat` is how many metres up
* the seat it sits on is, if it's sitting. */
show({ pose, facing, phase, seated, on, run }: Drawn, tint: number, seat = 0): this {
+ if (this.cache.figure !== this.figure) this.cache = new PoseCache(this.figure);
const shown = this.cache.get(pose, facing, phase, seated, on, run);
this.draw(shown.pose === 'sit' && seat > 0 ? this.seated(shown, seat) : shown);
if (tint !== this.tint) {
diff --git a/client/src/world/README.md b/client/src/world/README.md
index 6b92e624..4ea99e62 100644
--- a/client/src/world/README.md
+++ b/client/src/world/README.md
@@ -12,7 +12,8 @@ Run it with `make dev` and open http://localhost:5173. The residents go about th
`assets.ts` bundles every street and floor map in [`content/street/`](../../../content/street/README.md):
`maps` has them all by id, and `mapIds` their ids. `maps` is one object that's never replaced, since
`ui/side-view.ts` tells side views by it. The dolls are part 4's (`dolls`, from `dolls/assets.ts`), and
-someone without one of their own gets a resident's, the same one every time (`dollFor`).
+someone without one of their own gets a resident's, the same one every time (`dollFor`). The
+player's is the one for the bionics `player_state` last said were fitted (`playerBody`).
## Maps
diff --git a/client/src/world/assets.test.ts b/client/src/world/assets.test.ts
index ec85b49d..7f93a739 100644
--- a/client/src/world/assets.test.ts
+++ b/client/src/world/assets.test.ts
@@ -1,5 +1,16 @@
import { describe, expect, it } from 'vitest';
-import { dollFor, dolls, mapIds, maps } from './assets';
+import { PlayerBody } from '../dolls/bionics';
+import { server, type Body } from '../net';
+import { dollFor, dolls, mapIds, maps, playerBody } from './assets';
+
+const whole: Body = {
+ legs: 'present',
+ vertebra: 'present',
+ left_eye: 'present',
+ right_eye: 'present',
+ left_kidney: 'present',
+ right_kidney: 'present',
+};
describe('maps', () => {
it('has every street and floor, bundled', () => {
@@ -19,7 +30,28 @@ describe('dollFor', () => {
expect(dollFor('player')).toBe(dolls.player);
const stranger = dollFor('wren_abernethy');
expect(dollFor('wren_abernethy')).toBe(stranger);
- expect(stranger).not.toBe(dolls.player);
+ expect(stranger.id).not.toMatch(/^player/);
expect(Object.values(dolls)).toContain(stranger);
});
+
+ it('is the player’s with whatever bionics are fitted', () => {
+ const body = new PlayerBody();
+ expect(dollFor('player', body)).toBe(dolls.player);
+
+ body.set({ ...whole, legs: 'hydraulic_legs', right_eye: 'infrared_eye' });
+
+ expect(dollFor('player', body)).toBe(dolls.player_legs_eye);
+ expect(dollFor('kofi', body)).toBe(dolls.kofi);
+ });
+
+ it('hears what’s fitted from player_state', () => {
+ const player = { money: 0, items: [], carry_limit: 10, hunger: 0, body: { ...whole, vertebra: 'datajack' } };
+
+ server.dispatch(JSON.stringify({ type: 'player_state', player }));
+
+ expect(playerBody.figure).toBe('player_datajack');
+ expect(dollFor('player')).toBe(dolls.player_datajack);
+ server.dispatch(JSON.stringify({ type: 'player_state', player: { ...player, body: whole } }));
+ expect(dollFor('player')).toBe(dolls.player);
+ });
});
diff --git a/client/src/world/assets.ts b/client/src/world/assets.ts
index dcc81fed..08ffa208 100644
--- a/client/src/world/assets.ts
+++ b/client/src/world/assets.ts
@@ -1,5 +1,7 @@
import { dolls } from '../dolls/assets';
+import { isPlayerFigure, PLAYER_FIGURE, PlayerBody } from '../dolls/bionics';
import type { FigureManifest } from '../dolls/manifest';
+import { server } from '../net';
import type { TiledMap } from './map';
const files = import.meta.glob(
@@ -17,18 +19,22 @@ export const mapIds: readonly string[] = Object.keys(maps);
export { dolls };
-const PLAYER = 'player';
const residentDolls = Object.keys(dolls)
- .filter((id) => id !== PLAYER)
+ .filter((id) => !isPlayerFigure(id))
.sort();
+/** What the player has fitted, from `player_state`. */
+export const playerBody = new PlayerBody();
+server.on('player_state', ({ player }) => playerBody.set(player.body));
+
function fnv1a(text: string): number {
let hash = 0x811c9dc5;
for (let i = 0; i < text.length; i++) hash = Math.imul(hash ^ text.charCodeAt(i), 0x01000193);
return hash >>> 0;
}
-/** Its own doll, or a resident's picked by its id, the same one every time. */
-export function dollFor(id: string): FigureManifest {
+/** Its own doll, the player's for `body`, or a resident's picked by its id, the same every time. */
+export function dollFor(id: string, body: PlayerBody = playerBody): FigureManifest {
+ if (id === PLAYER_FIGURE) return dolls[body.figure] ?? dolls[PLAYER_FIGURE];
return dolls[id] ?? dolls[residentDolls[fnv1a(id) % residentDolls.length]];
}
diff --git a/content/dolls/README.md b/content/dolls/README.md
index e7657490..0c08c805 100644
--- a/content/dolls/README.md
+++ b/content/dolls/README.md
@@ -9,14 +9,18 @@ The client swaps, flips, bobs and sways the painted frames (`client/src/dolls/`)
uv sync --extra images
uv run simile dolls paint # every figure whose Appearance or pipeline changed
uv run simile dolls paint kofi --force --paintings /tmp/dolls # one again, saving its paintings
+uv run simile dolls paint player # the player, and with each combination of bionics
uv run simile dolls seats # measure every seat again from the atlases, painting nothing
```
-Don't edit them by hand. CI never paints; the tests check what's committed.
+Don't edit them by hand. CI never paints; the tests check what's committed. Painting waits
+while another image model, a `simile papercraft` or `simile dolls` run, or an Ollama model is
+using the GPU.
## Files
-For each resident and `player`:
+For each resident, `player`, and the player with each combination of bionics (`player_legs`,
+`player_datajack_eye`, `player_legs_datajack_eye` and so on, below):
| File | What it is |
|---|---|
@@ -113,6 +117,44 @@ Each painting is cached in `server/data/dolls/paintings/` by everything it was p
cutting and matching can be retuned without painting again. A painting that comes out off-model
gets a reroll in `build.REROLLS` (`"/": n`), which repaints that frame with another seed.
+## The player with bionics
+
+Seven more figures, one for each combination of the hydraulic legs, the datajack and the
+infrared eye (`server/simile/dolls/bionics.py`), with the player's frames and poses. Their
+manifests say what's `fitted`, and their `hash` is the whole player's with each part's prompts
+and rerolls, and `bionics.VERSION`.
+
+1. **Each part is painted once on every frame that shows it,** as an edit of the whole player's
+ same frame: 20 paintings. The eye isn't seen from behind, so no climb has it.
+ - The legs are steel hydraulic shins below shorts, in the same sneakers.
+ - The datajack is a chrome port with a green light. FLUX paints it on a band round the neck
+ however it's asked not to, so that's how it looks, the same in every frame.
+ - The eye is a red lens in a chrome rim.
+2. **Each part is checked as the residents' poses are,** against the whole player's standing
+ figure, and painted again if it doesn't show (`bionics.missing`):
+ - steel over 12% of the legs, which the grey shading of the whole player's sneakers stays
+ under (about 6%), while hydraulic legs are 13 to 23%;
+ - the green light;
+ - one red lens, not two, no wider than 17% of the head, as the walk's lenses are (about
+ 15%), so it doesn't grow when the player stops.
+
+ The face check spares the lens and its rim (`bionics.spared`). A frame may show the parts'
+ steel, red and green (`bionics.PALETTE`) without their counting as strays. With the legs, the
+ standing colour check looks for the bottom colour in the shorts (`bionics.SHORTS`).
+3. **Laid over the whole player** (`bionics.changed`, `bionics.laid`): each part is what its edit
+ changed of the whole player's frame, near the part (anything else FLUX redrew, a backpack a
+ shade lighter, is left out), grown by its outline and feathered. Each combination is the whole
+ player's frames with its parts laid over them, so the player is the same in every combination
+ but for the parts, and each part is the same in every combination it's in.
+4. **Scaled as the whole player is** (`sprites.frames`' `reference`), so fitting a part doesn't
+ change the player's size.
+
+The eye is painted where it shows, nearer the ear, whichever socket the sim fits it in. Both eyes
+show in three-quarter view, and the client mirrors a figure to face west, so the eye swaps sockets
+as the player turns, as every figure's hair parting and leading leg do. Painting the far socket
+too would take another painting of the eye on every frame, and would show it as a red speck by the
+nose whenever the player faced away from it.
+
## The flat-doll route
`POST /api/dolls/flat` with `{"appearance": {...}, "character": "new_face"}` (`character`
diff --git a/content/dolls/player_datajack.json b/content/dolls/player_datajack.json
new file mode 100644
index 00000000..c0448690
--- /dev/null
+++ b/content/dolls/player_datajack.json
@@ -0,0 +1,136 @@
+{
+ "id": "player_datajack",
+ "hash": "df534cc00470fcdf",
+ "appearance": {
+ "skin": "#e4a672",
+ "hair_style": "short",
+ "hair_colour": "#3e2731",
+ "top_colour": "#0099db",
+ "bottom_colour": "#3a4466",
+ "shoe_colour": "#ffffff",
+ "build": "average",
+ "accessories": [
+ "backpack"
+ ]
+ },
+ "fitted": [
+ "datajack"
+ ],
+ "version": 3,
+ "ppm": 80,
+ "height": 163.2,
+ "painted": "player_datajack.webp",
+ "frames": {
+ "climb": {
+ "frame": [
+ 85,
+ 0,
+ 85,
+ 166
+ ],
+ "feet": [
+ 44.7,
+ 164.0
+ ]
+ },
+ "pass": {
+ "frame": [
+ 172,
+ 0,
+ 65,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "run": {
+ "frame": [
+ 489,
+ 0,
+ 101,
+ 136
+ ],
+ "feet": [
+ 58.0,
+ 166.0
+ ]
+ },
+ "run_pass": {
+ "frame": [
+ 0,
+ 0,
+ 83,
+ 167
+ ],
+ "feet": [
+ 44.0,
+ 165.0
+ ]
+ },
+ "sit": {
+ "frame": [
+ 394,
+ 0,
+ 93,
+ 150
+ ],
+ "feet": [
+ 49.5,
+ 148.0
+ ],
+ "seat": [
+ 49.5,
+ 119.0
+ ]
+ },
+ "stand": {
+ "frame": [
+ 239,
+ 0,
+ 67,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "stride": {
+ "frame": [
+ 308,
+ 0,
+ 84,
+ 165
+ ],
+ "feet": [
+ 39.0,
+ 163.0
+ ]
+ }
+ },
+ "poses": {
+ "stand": [
+ "stand"
+ ],
+ "walk": [
+ "stride",
+ "pass",
+ "stride",
+ "pass"
+ ],
+ "run": [
+ "run",
+ "run_pass",
+ "run",
+ "run_pass"
+ ],
+ "sit": [
+ "sit"
+ ],
+ "climb": [
+ "climb"
+ ]
+ }
+}
diff --git a/content/dolls/player_datajack.webp b/content/dolls/player_datajack.webp
new file mode 100644
index 00000000..9c561f88
Binary files /dev/null and b/content/dolls/player_datajack.webp differ
diff --git a/content/dolls/player_datajack_eye.json b/content/dolls/player_datajack_eye.json
new file mode 100644
index 00000000..d7e7d03e
--- /dev/null
+++ b/content/dolls/player_datajack_eye.json
@@ -0,0 +1,137 @@
+{
+ "id": "player_datajack_eye",
+ "hash": "112ad47c7c281268",
+ "appearance": {
+ "skin": "#e4a672",
+ "hair_style": "short",
+ "hair_colour": "#3e2731",
+ "top_colour": "#0099db",
+ "bottom_colour": "#3a4466",
+ "shoe_colour": "#ffffff",
+ "build": "average",
+ "accessories": [
+ "backpack"
+ ]
+ },
+ "fitted": [
+ "datajack",
+ "infrared_eye"
+ ],
+ "version": 3,
+ "ppm": 80,
+ "height": 163.2,
+ "painted": "player_datajack_eye.webp",
+ "frames": {
+ "climb": {
+ "frame": [
+ 85,
+ 0,
+ 85,
+ 166
+ ],
+ "feet": [
+ 44.7,
+ 164.0
+ ]
+ },
+ "pass": {
+ "frame": [
+ 172,
+ 0,
+ 65,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "run": {
+ "frame": [
+ 489,
+ 0,
+ 101,
+ 136
+ ],
+ "feet": [
+ 58.0,
+ 166.0
+ ]
+ },
+ "run_pass": {
+ "frame": [
+ 0,
+ 0,
+ 83,
+ 167
+ ],
+ "feet": [
+ 44.0,
+ 165.0
+ ]
+ },
+ "sit": {
+ "frame": [
+ 394,
+ 0,
+ 93,
+ 150
+ ],
+ "feet": [
+ 49.5,
+ 148.0
+ ],
+ "seat": [
+ 49.5,
+ 119.0
+ ]
+ },
+ "stand": {
+ "frame": [
+ 239,
+ 0,
+ 67,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "stride": {
+ "frame": [
+ 308,
+ 0,
+ 84,
+ 165
+ ],
+ "feet": [
+ 39.0,
+ 163.0
+ ]
+ }
+ },
+ "poses": {
+ "stand": [
+ "stand"
+ ],
+ "walk": [
+ "stride",
+ "pass",
+ "stride",
+ "pass"
+ ],
+ "run": [
+ "run",
+ "run_pass",
+ "run",
+ "run_pass"
+ ],
+ "sit": [
+ "sit"
+ ],
+ "climb": [
+ "climb"
+ ]
+ }
+}
diff --git a/content/dolls/player_datajack_eye.webp b/content/dolls/player_datajack_eye.webp
new file mode 100644
index 00000000..766519a7
Binary files /dev/null and b/content/dolls/player_datajack_eye.webp differ
diff --git a/content/dolls/player_eye.json b/content/dolls/player_eye.json
new file mode 100644
index 00000000..1af19178
--- /dev/null
+++ b/content/dolls/player_eye.json
@@ -0,0 +1,136 @@
+{
+ "id": "player_eye",
+ "hash": "d154a483516fafac",
+ "appearance": {
+ "skin": "#e4a672",
+ "hair_style": "short",
+ "hair_colour": "#3e2731",
+ "top_colour": "#0099db",
+ "bottom_colour": "#3a4466",
+ "shoe_colour": "#ffffff",
+ "build": "average",
+ "accessories": [
+ "backpack"
+ ]
+ },
+ "fitted": [
+ "infrared_eye"
+ ],
+ "version": 3,
+ "ppm": 80,
+ "height": 163.2,
+ "painted": "player_eye.webp",
+ "frames": {
+ "climb": {
+ "frame": [
+ 85,
+ 0,
+ 85,
+ 166
+ ],
+ "feet": [
+ 44.7,
+ 164.0
+ ]
+ },
+ "pass": {
+ "frame": [
+ 172,
+ 0,
+ 65,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "run": {
+ "frame": [
+ 489,
+ 0,
+ 101,
+ 136
+ ],
+ "feet": [
+ 58.0,
+ 166.0
+ ]
+ },
+ "run_pass": {
+ "frame": [
+ 0,
+ 0,
+ 83,
+ 167
+ ],
+ "feet": [
+ 44.0,
+ 165.0
+ ]
+ },
+ "sit": {
+ "frame": [
+ 394,
+ 0,
+ 93,
+ 150
+ ],
+ "feet": [
+ 49.5,
+ 148.0
+ ],
+ "seat": [
+ 49.5,
+ 119.0
+ ]
+ },
+ "stand": {
+ "frame": [
+ 239,
+ 0,
+ 67,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "stride": {
+ "frame": [
+ 308,
+ 0,
+ 84,
+ 165
+ ],
+ "feet": [
+ 39.0,
+ 163.0
+ ]
+ }
+ },
+ "poses": {
+ "stand": [
+ "stand"
+ ],
+ "walk": [
+ "stride",
+ "pass",
+ "stride",
+ "pass"
+ ],
+ "run": [
+ "run",
+ "run_pass",
+ "run",
+ "run_pass"
+ ],
+ "sit": [
+ "sit"
+ ],
+ "climb": [
+ "climb"
+ ]
+ }
+}
diff --git a/content/dolls/player_eye.webp b/content/dolls/player_eye.webp
new file mode 100644
index 00000000..61b84c95
Binary files /dev/null and b/content/dolls/player_eye.webp differ
diff --git a/content/dolls/player_legs.json b/content/dolls/player_legs.json
new file mode 100644
index 00000000..885385cc
--- /dev/null
+++ b/content/dolls/player_legs.json
@@ -0,0 +1,136 @@
+{
+ "id": "player_legs",
+ "hash": "e94fe7a11c542307",
+ "appearance": {
+ "skin": "#e4a672",
+ "hair_style": "short",
+ "hair_colour": "#3e2731",
+ "top_colour": "#0099db",
+ "bottom_colour": "#3a4466",
+ "shoe_colour": "#ffffff",
+ "build": "average",
+ "accessories": [
+ "backpack"
+ ]
+ },
+ "fitted": [
+ "hydraulic_legs"
+ ],
+ "version": 3,
+ "ppm": 80,
+ "height": 163.2,
+ "painted": "player_legs.webp",
+ "frames": {
+ "climb": {
+ "frame": [
+ 85,
+ 0,
+ 85,
+ 166
+ ],
+ "feet": [
+ 44.5,
+ 164.0
+ ]
+ },
+ "pass": {
+ "frame": [
+ 172,
+ 0,
+ 65,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "run": {
+ "frame": [
+ 489,
+ 0,
+ 101,
+ 136
+ ],
+ "feet": [
+ 58.5,
+ 165.0
+ ]
+ },
+ "run_pass": {
+ "frame": [
+ 0,
+ 0,
+ 83,
+ 167
+ ],
+ "feet": [
+ 44.0,
+ 164.0
+ ]
+ },
+ "sit": {
+ "frame": [
+ 394,
+ 0,
+ 93,
+ 150
+ ],
+ "feet": [
+ 49.5,
+ 148.0
+ ],
+ "seat": [
+ 49.5,
+ 119.0
+ ]
+ },
+ "stand": {
+ "frame": [
+ 239,
+ 0,
+ 67,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "stride": {
+ "frame": [
+ 308,
+ 0,
+ 84,
+ 165
+ ],
+ "feet": [
+ 39.0,
+ 163.0
+ ]
+ }
+ },
+ "poses": {
+ "stand": [
+ "stand"
+ ],
+ "walk": [
+ "stride",
+ "pass",
+ "stride",
+ "pass"
+ ],
+ "run": [
+ "run",
+ "run_pass",
+ "run",
+ "run_pass"
+ ],
+ "sit": [
+ "sit"
+ ],
+ "climb": [
+ "climb"
+ ]
+ }
+}
diff --git a/content/dolls/player_legs.webp b/content/dolls/player_legs.webp
new file mode 100644
index 00000000..6335d26b
Binary files /dev/null and b/content/dolls/player_legs.webp differ
diff --git a/content/dolls/player_legs_datajack.json b/content/dolls/player_legs_datajack.json
new file mode 100644
index 00000000..2c799726
--- /dev/null
+++ b/content/dolls/player_legs_datajack.json
@@ -0,0 +1,137 @@
+{
+ "id": "player_legs_datajack",
+ "hash": "51ed17ca6635a1a5",
+ "appearance": {
+ "skin": "#e4a672",
+ "hair_style": "short",
+ "hair_colour": "#3e2731",
+ "top_colour": "#0099db",
+ "bottom_colour": "#3a4466",
+ "shoe_colour": "#ffffff",
+ "build": "average",
+ "accessories": [
+ "backpack"
+ ]
+ },
+ "fitted": [
+ "hydraulic_legs",
+ "datajack"
+ ],
+ "version": 3,
+ "ppm": 80,
+ "height": 163.2,
+ "painted": "player_legs_datajack.webp",
+ "frames": {
+ "climb": {
+ "frame": [
+ 85,
+ 0,
+ 85,
+ 166
+ ],
+ "feet": [
+ 44.5,
+ 164.0
+ ]
+ },
+ "pass": {
+ "frame": [
+ 172,
+ 0,
+ 65,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "run": {
+ "frame": [
+ 489,
+ 0,
+ 101,
+ 136
+ ],
+ "feet": [
+ 58.5,
+ 165.0
+ ]
+ },
+ "run_pass": {
+ "frame": [
+ 0,
+ 0,
+ 83,
+ 167
+ ],
+ "feet": [
+ 44.0,
+ 164.0
+ ]
+ },
+ "sit": {
+ "frame": [
+ 394,
+ 0,
+ 93,
+ 150
+ ],
+ "feet": [
+ 49.5,
+ 148.0
+ ],
+ "seat": [
+ 49.5,
+ 119.0
+ ]
+ },
+ "stand": {
+ "frame": [
+ 239,
+ 0,
+ 67,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "stride": {
+ "frame": [
+ 308,
+ 0,
+ 84,
+ 165
+ ],
+ "feet": [
+ 39.0,
+ 163.0
+ ]
+ }
+ },
+ "poses": {
+ "stand": [
+ "stand"
+ ],
+ "walk": [
+ "stride",
+ "pass",
+ "stride",
+ "pass"
+ ],
+ "run": [
+ "run",
+ "run_pass",
+ "run",
+ "run_pass"
+ ],
+ "sit": [
+ "sit"
+ ],
+ "climb": [
+ "climb"
+ ]
+ }
+}
diff --git a/content/dolls/player_legs_datajack.webp b/content/dolls/player_legs_datajack.webp
new file mode 100644
index 00000000..e5b25061
Binary files /dev/null and b/content/dolls/player_legs_datajack.webp differ
diff --git a/content/dolls/player_legs_datajack_eye.json b/content/dolls/player_legs_datajack_eye.json
new file mode 100644
index 00000000..a95827b1
--- /dev/null
+++ b/content/dolls/player_legs_datajack_eye.json
@@ -0,0 +1,138 @@
+{
+ "id": "player_legs_datajack_eye",
+ "hash": "28a3784cd148cffe",
+ "appearance": {
+ "skin": "#e4a672",
+ "hair_style": "short",
+ "hair_colour": "#3e2731",
+ "top_colour": "#0099db",
+ "bottom_colour": "#3a4466",
+ "shoe_colour": "#ffffff",
+ "build": "average",
+ "accessories": [
+ "backpack"
+ ]
+ },
+ "fitted": [
+ "hydraulic_legs",
+ "datajack",
+ "infrared_eye"
+ ],
+ "version": 3,
+ "ppm": 80,
+ "height": 163.2,
+ "painted": "player_legs_datajack_eye.webp",
+ "frames": {
+ "climb": {
+ "frame": [
+ 85,
+ 0,
+ 85,
+ 166
+ ],
+ "feet": [
+ 44.5,
+ 164.0
+ ]
+ },
+ "pass": {
+ "frame": [
+ 172,
+ 0,
+ 65,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "run": {
+ "frame": [
+ 489,
+ 0,
+ 101,
+ 136
+ ],
+ "feet": [
+ 58.5,
+ 165.0
+ ]
+ },
+ "run_pass": {
+ "frame": [
+ 0,
+ 0,
+ 83,
+ 167
+ ],
+ "feet": [
+ 44.0,
+ 164.0
+ ]
+ },
+ "sit": {
+ "frame": [
+ 394,
+ 0,
+ 93,
+ 150
+ ],
+ "feet": [
+ 49.5,
+ 148.0
+ ],
+ "seat": [
+ 49.5,
+ 119.0
+ ]
+ },
+ "stand": {
+ "frame": [
+ 239,
+ 0,
+ 67,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "stride": {
+ "frame": [
+ 308,
+ 0,
+ 84,
+ 165
+ ],
+ "feet": [
+ 39.0,
+ 163.0
+ ]
+ }
+ },
+ "poses": {
+ "stand": [
+ "stand"
+ ],
+ "walk": [
+ "stride",
+ "pass",
+ "stride",
+ "pass"
+ ],
+ "run": [
+ "run",
+ "run_pass",
+ "run",
+ "run_pass"
+ ],
+ "sit": [
+ "sit"
+ ],
+ "climb": [
+ "climb"
+ ]
+ }
+}
diff --git a/content/dolls/player_legs_datajack_eye.webp b/content/dolls/player_legs_datajack_eye.webp
new file mode 100644
index 00000000..dd25c88d
Binary files /dev/null and b/content/dolls/player_legs_datajack_eye.webp differ
diff --git a/content/dolls/player_legs_eye.json b/content/dolls/player_legs_eye.json
new file mode 100644
index 00000000..a7e6116f
--- /dev/null
+++ b/content/dolls/player_legs_eye.json
@@ -0,0 +1,137 @@
+{
+ "id": "player_legs_eye",
+ "hash": "a0443e91a18a1cc6",
+ "appearance": {
+ "skin": "#e4a672",
+ "hair_style": "short",
+ "hair_colour": "#3e2731",
+ "top_colour": "#0099db",
+ "bottom_colour": "#3a4466",
+ "shoe_colour": "#ffffff",
+ "build": "average",
+ "accessories": [
+ "backpack"
+ ]
+ },
+ "fitted": [
+ "hydraulic_legs",
+ "infrared_eye"
+ ],
+ "version": 3,
+ "ppm": 80,
+ "height": 163.2,
+ "painted": "player_legs_eye.webp",
+ "frames": {
+ "climb": {
+ "frame": [
+ 85,
+ 0,
+ 85,
+ 166
+ ],
+ "feet": [
+ 44.5,
+ 164.0
+ ]
+ },
+ "pass": {
+ "frame": [
+ 172,
+ 0,
+ 65,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "run": {
+ "frame": [
+ 489,
+ 0,
+ 101,
+ 136
+ ],
+ "feet": [
+ 58.5,
+ 165.0
+ ]
+ },
+ "run_pass": {
+ "frame": [
+ 0,
+ 0,
+ 83,
+ 167
+ ],
+ "feet": [
+ 44.0,
+ 164.0
+ ]
+ },
+ "sit": {
+ "frame": [
+ 394,
+ 0,
+ 93,
+ 150
+ ],
+ "feet": [
+ 49.5,
+ 148.0
+ ],
+ "seat": [
+ 49.5,
+ 119.0
+ ]
+ },
+ "stand": {
+ "frame": [
+ 239,
+ 0,
+ 67,
+ 166
+ ],
+ "feet": [
+ 38.0,
+ 164.0
+ ]
+ },
+ "stride": {
+ "frame": [
+ 308,
+ 0,
+ 84,
+ 165
+ ],
+ "feet": [
+ 39.0,
+ 163.0
+ ]
+ }
+ },
+ "poses": {
+ "stand": [
+ "stand"
+ ],
+ "walk": [
+ "stride",
+ "pass",
+ "stride",
+ "pass"
+ ],
+ "run": [
+ "run",
+ "run_pass",
+ "run",
+ "run_pass"
+ ],
+ "sit": [
+ "sit"
+ ],
+ "climb": [
+ "climb"
+ ]
+ }
+}
diff --git a/content/dolls/player_legs_eye.webp b/content/dolls/player_legs_eye.webp
new file mode 100644
index 00000000..becd7fc9
Binary files /dev/null and b/content/dolls/player_legs_eye.webp differ
diff --git a/server/simile/dolls/bionics.py b/server/simile/dolls/bionics.py
new file mode 100644
index 00000000..f0c7dd2a
--- /dev/null
+++ b/server/simile/dolls/bionics.py
@@ -0,0 +1,222 @@
+"""The player with each combination of bionics: each part painted once over the player's frames."""
+
+from collections.abc import Iterable
+from itertools import combinations
+
+import numpy as np
+from numpy.typing import NDArray
+from PIL import Image, ImageFilter
+
+from simile.dolls.colour import cielab, delta_e_lab
+from simile.dolls.figure import EDITS, FACE, styled, touching
+from simile.dolls.sprites import head
+from simile.dolls.words import described
+from simile.schema import PLAYER_ID, Appearance
+from simile.schema.player import Bionic
+
+VERSION = 1
+"""How the parts are laid over the whole player: a new one lays them again."""
+FITTED = (Bionic.HYDRAULIC_LEGS, Bionic.DATAJACK, Bionic.INFRARED_EYE)
+EYE = Bionic.INFRARED_EYE
+NAMES = {Bionic.HYDRAULIC_LEGS: "legs", Bionic.DATAJACK: "datajack", Bionic.INFRARED_EYE: "eye"}
+type Combination = tuple[Bionic, ...]
+COMBINATIONS: tuple[Combination, ...] = tuple(
+ c for n in range(1, len(FITTED) + 1) for c in combinations(FITTED, n)
+)
+"""Every combination of bionics but none, fewest first, each in `FITTED`'s order."""
+WORDS = {
+ Bionic.HYDRAULIC_LEGS: (
+ "both legs replaced by bionic hydraulic legs: the trousers now end above the knees as "
+ "shorts, and below them each lower leg is bare steel-grey machinery with a shiny chrome "
+ "hydraulic piston down the shin and a round metal knee joint, ending in the same white "
+ "sneakers"
+ ),
+ Bionic.DATAJACK: (
+ "a datajack port set into the skin at the back of the neck, just under the hair at the "
+ "nape: a chunky round chrome socket with a bright green light in its middle, and no "
+ "collar, band or ring around the neck"
+ ),
+ Bionic.INFRARED_EYE: (
+ "the eye nearer the ear replaced by a small bionic infrared eye, no bigger than the other "
+ "eye: a bright red lens with a thin chrome rim. The other eye, beside the nose, stays a "
+ "plain dark dot eye, and the skin around both eyes is clean and unchanged"
+ ),
+}
+EYE_FACE = (
+ "The face is clean, with a small smile and no smudges on it: one bright red bionic eye nearer "
+ "the ear and one plain dark dot eye beside the nose."
+)
+METAL = (12.0, 40.0, 88.0)
+"""Steel and chrome, CIELAB: chroma below the first, lightness between the others."""
+LEGS = 0.6
+"""Where the legs start, down the figure."""
+SHORTS = (0.62, 0.7)
+"""The bottom colour's `measure.CORES` with the hydraulic legs, whose shins are steel."""
+LENS = (40.0, 15.0)
+"""The red lens: a* above the first and lightness above the second, in the top half."""
+LIGHT = (-30.0, 15.0)
+"""The datajack's green light: a* below the first, b* above the second."""
+LEAST = {Bionic.HYDRAULIC_LEGS: 0.12, Bionic.DATAJACK: 0.001, Bionic.INFRARED_EYE: 0.002}
+"""The share of a frame, or of its legs for the legs, a part must cover to be there."""
+LENS_WIDE = 0.17
+"""The widest the lens may be, as a share of the head's width: the walk's is about 0.15."""
+OTHER_EYE = 0.25
+"""A second red patch this big against the first is the other eye painted red."""
+RIM = 0.012
+"""How far round the lens, in the figure's heights, its rim reaches: the face check spares it."""
+PALETTE = np.array(
+ [[lightness, 0.0, 0.0] for lightness in range(35, 95, 10)]
+ + [[48.0, 70.0, 55.0], [80.0, -70.0, 65.0]]
+)
+"""The parts' colours in CIELAB, which a frame may show although the whole player doesn't."""
+CHANGED = 10.0
+"""How far a pixel of a part's frame is from the whole player's, CIEDE2000, to be changed."""
+SHORTS_FROM = 0.5
+"""How far down the figure the legs' changes start: the shorts' hem is above the knees."""
+REACH = {Bionic.DATAJACK: 0.06, Bionic.INFRARED_EYE: 0.03}
+"""How far from its light or lens, in the figure's heights, a part's changes are its own."""
+GROW = 6
+"""Pixels round what changed laid with it, for its outline."""
+FEATHER = 2.0
+"""The blur, in pixels, over the edge of what's laid."""
+
+
+def figure_id(fitted: Iterable[Bionic]) -> str:
+ """The figure for a body with `fitted`: `player` with none, `player_legs_eye` and so on."""
+ have = set(fitted)
+ return "_".join([PLAYER_ID, *(NAMES[b] for b in FITTED if b in have)])
+
+
+def faces(frame: str) -> bool:
+ return frame == "stand" or EDITS[frame].face
+
+
+def shows(bionic: Bionic, frame: str) -> bool:
+ """Whether `frame` shows the part: the eye isn't seen from behind."""
+ return bionic != Bionic.INFRARED_EYE or faces(frame)
+
+
+def edit_prompt(frame: str, bionic: Bionic, appearance: Appearance) -> str:
+ """Adds `bionic` to the whole player's `frame`."""
+ words = described(appearance, PLAYER_ID)
+ if bionic == Bionic.HYDRAULIC_LEGS:
+ words = words.replace(" trousers ", " shorts ")
+ face = ""
+ if faces(frame):
+ face = f" {EYE_FACE if bionic == Bionic.INFRARED_EYE else FACE}"
+ return styled(
+ "Redraw image 1 with the same character, unchanged in every other detail and the same "
+ f"size, in exactly the same pose and view, now with {WORDS[bionic]}. {words}{face} "
+ "Keep the background plain light grey."
+ )
+
+
+def regions(figure: Image.Image) -> tuple[NDArray[np.bool_], NDArray[np.float64]]:
+ """The opaque figure, and how far down it each row is, 0 at the crown and 1 at the soles."""
+ solid = np.asarray(figure.getchannel("A")) > 200
+ rows = np.flatnonzero(solid.any(axis=1))
+ top, bottom = (rows[0], rows[-1]) if len(rows) else (0, solid.shape[0] - 1)
+ down = (np.arange(solid.shape[0], dtype=np.float64) - top) / max(1, bottom - top)
+ return solid, np.broadcast_to(down[:, None], solid.shape)
+
+
+def height(solid: NDArray[np.bool_]) -> int:
+ rows = np.flatnonzero(solid.any(axis=1))
+ return int(rows[-1] - rows[0]) if len(rows) else 0
+
+
+def parts(figure: Image.Image) -> dict[Bionic, NDArray[np.bool_]]:
+ """Where each part's colours are: steel in the legs, the green light and the red lens."""
+ solid, down = regions(figure)
+ lab = cielab(np.asarray(figure.convert("RGB"), dtype=np.float64))
+ light, a, b = lab[..., 0], lab[..., 1], lab[..., 2]
+ metal = (np.hypot(a, b) < METAL[0]) & (light > METAL[1]) & (light < METAL[2])
+ return {
+ Bionic.HYDRAULIC_LEGS: solid & metal & (down >= LEGS),
+ Bionic.DATAJACK: solid & (a < LIGHT[0]) & (b > LIGHT[1]),
+ Bionic.INFRARED_EYE: solid & (a > LENS[0]) & (light > LENS[1]) & (down < 0.5),
+ }
+
+
+def patches(mask: NDArray[np.bool_]) -> list[NDArray[np.bool_]]:
+ """`mask`'s patches, corners joining them, biggest first."""
+ left = mask.copy()
+ found = []
+ while left.any():
+ y, x = np.argwhere(left)[0]
+ found.append(touching(left, int(x), int(y)))
+ left &= ~found[-1]
+ return sorted(found, key=lambda patch: -int(patch.sum()))
+
+
+def missing(
+ figure: Image.Image, combination: Combination, frame: str, least: float = 1.0
+) -> list[str]:
+ """What's wrong with the parts `frame` shows: one missing, two eyes, or an outsize eye."""
+ found = parts(figure)
+ solid, down = regions(figure)
+ out = []
+ for bionic in combination:
+ if not shows(bionic, frame):
+ continue
+ whole = solid & (down >= LEGS) if bionic == Bionic.HYDRAULIC_LEGS else solid
+ if found[bionic].sum() < least * LEAST[bionic] * whole.sum():
+ out.append(NAMES[bionic])
+ elif bionic == Bionic.INFRARED_EYE:
+ lenses = patches(found[bionic])
+ if len(lenses) > 1 and lenses[1].sum() >= OTHER_EYE * lenses[0].sum():
+ out.append("two eyes")
+ if lens_width(figure, lenses[0]) > LENS_WIDE / least:
+ out.append("eye too big")
+ return out
+
+
+def lens_width(figure: Image.Image, lens: NDArray[np.bool_]) -> float:
+ """How wide `lens` is, as a share of the head's width."""
+ columns = np.flatnonzero(lens.any(axis=0))
+ return (columns[-1] - columns[0] + 1) / max(head(figure)[1], 1)
+
+
+def grown(mask: NDArray[np.bool_], by: int) -> NDArray[np.bool_]:
+ image = Image.fromarray(mask.astype(np.uint8) * 255)
+ return np.asarray(image.filter(ImageFilter.MaxFilter(2 * by + 1))) > 0
+
+
+def spared(figure: Image.Image) -> NDArray[np.bool_]:
+ """The bionic eye and its rim, which the face check doesn't count as painted over."""
+ solid, _ = regions(figure)
+ reach = max(1, round(RIM * height(solid)))
+ return grown(parts(figure)[Bionic.INFRARED_EYE], reach)
+
+
+def changed(whole: Image.Image, edit: Image.Image, bionic: Bionic) -> NDArray[np.bool_]:
+ """Where the `edit` of the `whole` player's frame painted `bionic`, with its outline."""
+ a, b = np.asarray(whole.convert("RGBA")), np.asarray(edit.convert("RGBA"))
+ solid, down = regions(edit)
+ both = (a[..., 3] > 128) & (b[..., 3] > 128)
+ far = delta_e_lab(cielab(a[..., :3].astype(np.float64)), cielab(b[..., :3].astype(np.float64)))
+ diff = (np.abs(a[..., 3].astype(int) - b[..., 3].astype(int)) > 64) | (both & (far > CHANGED))
+ seeds = parts(edit)[bionic]
+ if bionic == Bionic.HYDRAULIC_LEGS:
+ diff &= down >= SHORTS_FROM
+ elif seeds.any():
+ seeds = patches(seeds)[0]
+ diff &= grown(seeds, round(REACH[bionic] * height(solid)))
+ joined = grown(diff | seeds, 2)
+ keep = np.zeros_like(joined)
+ for y, x in np.argwhere(seeds)[:: max(1, int(seeds.sum()) // 50)]:
+ if not keep[y, x]:
+ keep |= touching(joined, int(x), int(y))
+ return grown(keep, GROW)
+
+
+def laid(whole: Image.Image, layers: list[tuple[Image.Image, NDArray[np.bool_]]]) -> Image.Image:
+ """The `whole` player's frame with each layer's frame where it says, feathered."""
+ out = np.asarray(whole.convert("RGBA")).astype(np.float64)
+ for edit, where in layers:
+ soft = Image.fromarray(where.astype(np.uint8) * 255).filter(
+ ImageFilter.GaussianBlur(FEATHER)
+ )
+ weight = (np.asarray(soft) / 255.0)[..., None]
+ out = out * (1 - weight) + np.asarray(edit.convert("RGBA")).astype(np.float64) * weight
+ return Image.fromarray(np.round(out).astype(np.uint8), "RGBA")
diff --git a/server/simile/dolls/build.py b/server/simile/dolls/build.py
index 313227ea..acbfdc41 100644
--- a/server/simile/dolls/build.py
+++ b/server/simile/dolls/build.py
@@ -14,8 +14,11 @@
import numpy as np
from PIL import Image
+from simile.dolls import bionics
+from simile.dolls.bionics import COMBINATIONS, Combination, figure_id
from simile.dolls.figure import (
EDITS,
+ FRAMES,
POSES,
SIZE,
STANDING,
@@ -44,13 +47,14 @@
from simile.dolls.sprites import VERSION, atlas, cut_out, frames, manifest, scale, seat
from simile.dolls.words import gender_in, persona
from simile.schema import PLAYER_ID, Appearance
+from simile.schema.player import Bionic
REPO = Path(__file__).resolve().parents[3]
CONTENT = REPO / "content" / "dolls"
PAINTINGS = REPO / "server" / "data" / "dolls" / "paintings"
"""Every painting as FLUX returned it, by what it was painted from, so cutting and matching can
be retuned without painting again. Ignored by git."""
-TRIES = {"stand": 3, "edit": 2}
+TRIES = {"stand": 3, "edit": 2, "fitted": 3}
"""How many times a painting is made, at most, while its colours can't be matched."""
SCALE = 1.25
"""How much bigger or smaller than the standing figure FLUX may draw another pose before it's
@@ -58,7 +62,12 @@
WRONG = 20.0
"""A colour painted further than this from the `Appearance`, CIEDE2000, is another colour, not a
shade of it: rather than pulled back, the painting is made again."""
-REROLLS: dict[str, int] = {}
+REROLLS: dict[str, int] = {
+ "player_datajack/stride": 1,
+ "player_eye/stand": 3,
+ "player_eye/pass": 2,
+ "player_eye/sit": 8,
+}
"""`/` to a reroll number, for a painting that came out off-model by eye."""
MISSES: dict[tuple[str, str, str], str] = {
("henrietta", "climb", "shoe"): "ΔE 7.8: near-black shoes, seen from behind, among the outline",
@@ -99,6 +108,18 @@ def fingerprint(who: str, appearance: Appearance, said: str = "") -> str:
return hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()[:16]
+def fitted_fingerprint(combination: Combination, player: str, appearance: Appearance) -> str:
+ """What the player with bionics is made from: the player's, its parts' prompts and rerolls."""
+ ids = {figure_id((b,)) for b in combination}
+ inputs = {
+ "player": player,
+ "version": bionics.VERSION,
+ "prompts": [bionics.edit_prompt(f, b, appearance) for b in combination for f in FRAMES],
+ "rerolls": {k: v for k, v in REROLLS.items() if k.split("/")[0] in ids},
+ }
+ return hashlib.sha256(json.dumps(inputs, sort_keys=True).encode()).hexdigest()[:16]
+
+
def seed(who: str, frame: str, reroll: int = 0) -> int:
return zlib.crc32(f"{who}/{frame}/{reroll}".encode()) % 2**31
@@ -120,6 +141,8 @@ class Try:
followed: bool = False
"""Matched as the standing figure was painted, so a colour painted far off is the standing
figure's, already accepted, rather than another colour."""
+ missing: list[str] = field(default_factory=list)
+ """Bionics it should show but doesn't: `bionics.missing`."""
@dataclass
@@ -177,6 +200,8 @@ def attempt(
cut: Cut,
pose: Pose,
standing: Painted | None,
+ fitted: Combination = (),
+ frame: str = "stand",
) -> tuple[Try, Image.Image, Image.Image]:
"""One painting cut out and its colours pulled back to the `Appearance`, following how the
`standing` figure was painted, if this isn't it. With how far off they were before and after,
@@ -196,9 +221,11 @@ def attempt(
tried.lost = sorted(set(standing.colours) - set(found))
stand = standing.figures["stand"]
tried.scale = round(scale(fixed, stand, pose.bands == "climb"), 3)
- tried.strays = strays(fixed, stand)
+ tried.strays = strays(fixed, stand, bionics.PALETTE if fitted else None)
if pose.face:
- tried.marks = round(face_marks(fixed, appearance), 3)
+ eye = bionics.spared(fixed) if Bionic.INFRARED_EYE in fitted else None
+ tried.marks = round(face_marks(fixed, appearance, eye), 3)
+ tried.missing = bionics.missing(fixed, fitted, frame)
return tried, painting, fixed
@@ -211,9 +238,9 @@ def misses(tried: Try, appearance: Appearance) -> tuple[bool, float, float, floa
off_scale = abs(np.log(tried.scale)) > np.log(SCALE)
unlike = bool(tried.lost) or tried.strays > STRAYS
wrong = before > WRONG and not tried.followed
- bad = after > CLOSE or wrong or tried.marks > MARKS or off_scale or unlike
- odd = tried.marks + abs(np.log(tried.scale)) + tried.strays + len(tried.lost)
- return bad, odd, after, before
+ bad = after > CLOSE or wrong or tried.marks > MARKS or off_scale or unlike or tried.missing
+ odd = tried.marks + abs(np.log(tried.scale)) + tried.strays + len(tried.lost + tried.missing)
+ return bool(bad), odd, after, before
def paint_frame(
@@ -226,14 +253,17 @@ def paint_frame(
cut: Cut,
pose: Pose,
tries: int,
+ standing: Painted | None = None,
+ fitted: Combination = (),
) -> None:
"""Paints `frame` until it matches, at most `tries` times, keeping the best."""
first = REROLLS.get(f"{painted.who}/{frame}", 0)
- standing = painted if painted.figures.get("stand") else None
+ standing = standing or (painted if painted.figures.get("stand") else None)
made = []
for n in range(first, first + tries):
number = seed(painted.who, frame, n)
- made.append(attempt(painter, prompt, number, images, appearance, cut, pose, standing))
+ args = (painter, prompt, number, images, appearance, cut, pose, standing, fitted, frame)
+ made.append(attempt(*args))
if not misses(made[-1][0], appearance)[0]:
break
best = min(made, key=lambda m: misses(m[0], appearance))
@@ -268,16 +298,61 @@ def paint(
return painted
+def paint_parts(
+ player: Painted,
+ appearance: Appearance,
+ painter: Cached,
+ cut: Cut = cut_out,
+ log: Callable[[str], None] = print,
+) -> dict[Bionic, Painted]:
+ """Each bionic on every frame that shows it, as an edit of the `player`'s same frame."""
+ found = {}
+ for bionic in bionics.FITTED:
+ painted = Painted(figure_id((bionic,)), colours=player.colours)
+ for frame in FRAMES:
+ if not bionics.shows(bionic, frame):
+ continue
+ pose = STANDING if frame == "stand" else EDITS[frame]
+ prompt = bionics.edit_prompt(frame, bionic, appearance)
+ images, tries = [player.paintings[frame]], TRIES["fitted"]
+ args = (painter, painted, frame, prompt, images, appearance, cut, pose, tries)
+ paint_frame(*args, standing=player, fitted=(bionic,))
+ log(f"{painted.who} {frame}: {summary(painted.tries[frame][-1])}")
+ found[bionic] = painted
+ return found
+
+
+def fit(player: Painted, parts: dict[Bionic, Painted]) -> dict[Combination, dict]:
+ """Each combination's frames: the `player`'s, with its `parts` laid over them."""
+ layers = {
+ (bionic, frame): (figure, bionics.changed(player.figures[frame], figure, bionic))
+ for bionic, painted in parts.items()
+ for frame, figure in painted.figures.items()
+ }
+ return {
+ combination: {
+ frame: bionics.laid(
+ whole, [layers[b, frame] for b in combination if (b, frame) in layers]
+ )
+ for frame, whole in player.figures.items()
+ }
+ for combination in COMBINATIONS
+ }
+
+
def summary(tried: Try) -> str:
- return ", ".join(f"{k} {s.delta_e:g}" for k, s in tried.matched.items())
+ missing = f", missing {', '.join(tried.missing)}" if tried.missing else ""
+ return ", ".join(f"{k} {s.delta_e:g}" for k, s in tried.matched.items()) + missing
-def files(who: str, painted: Painted, **extra: Any) -> dict[str, bytes]:
+def files(
+ who: str, painted: Painted, standing: Image.Image | None = None, **extra: Any
+) -> dict[str, bytes]:
"""The manifest and the atlas, by file name."""
climbs = [f for f, pose in EDITS.items() if pose.bands == "climb"]
lifted = {f: pose.lifted for f, pose in EDITS.items() if pose.lifted}
seated = [f for f, pose in EDITS.items() if pose.seated]
- made = frames(painted.figures, hands_up=climbs, lifted=lifted, seated=seated)
+ made = frames(painted.figures, "stand", climbs, lifted, seated, standing)
data, places = atlas(made)
name = f"{who}.webp"
body = manifest(made, places, POSES, name, id=who, **extra)
@@ -322,14 +397,50 @@ def current(directory: Path, who: str, hash: str) -> bool:
def stale(directory: Path, keep: Iterable[str]) -> list[Path]:
- """Files in `directory` that belong to no one in `keep`: #310's atlases, or a resident's
- who's gone."""
+ """Files in `directory` that belong to no one in `keep` nor the player with bionics: #310's
+ atlases, or a resident's who's gone."""
+ keep = [*keep, *(figure_id(c) for c in COMBINATIONS)]
wanted = {name for who in keep for name in (f"{who}.json", f"{who}.webp")}
return [
p for p in directory.glob("*") if p.suffix in (".json", ".webp") and p.name not in wanted
]
+def build_fitted(
+ model: Cached,
+ directory: Path,
+ appearance: Appearance,
+ player: str,
+ whole: Painted | None,
+ force: bool = False,
+ cut: Cut = cut_out,
+ log: Callable[[str], None] = print,
+) -> list[Painted]:
+ """Paints the bionics over the `whole` player and lays them in every combination."""
+ hashes = {figure_id(c): fitted_fingerprint(c, player, appearance) for c in COMBINATIONS}
+ if not force and all(current(directory, who, h) for who, h in hashes.items()):
+ log("the player with bionics: up to date")
+ return []
+ whole = whole or paint(PLAYER_ID, appearance, model, cut=cut, log=lambda _: None)
+ parts = paint_parts(whole, appearance, model, cut, log)
+ dumped = appearance.model_dump(mode="json")
+ for combination, figures in fit(whole, parts).items():
+ who = figure_id(combination)
+ for frame, figure in figures.items():
+ if lost := bionics.missing(figure, combination, frame):
+ log(f"{who} {frame}: missing {', '.join(lost)}")
+ extra = {
+ "hash": hashes[who],
+ "appearance": dumped,
+ "fitted": [b.value for b in combination],
+ }
+ changed = write(
+ directory, files(who, Painted(who, figures=figures), whole.figures["stand"], **extra)
+ )
+ log(f"{who}: wrote {len(changed)} files")
+ return list(parts.values())
+
+
def build(
painter: Callable[[], AbstractContextManager[Painter]],
directory: Path = CONTENT,
@@ -340,7 +451,7 @@ def build(
log: Callable[[str], None] = print,
) -> list[Painted]:
"""Paints each figure whose fingerprint changed, loading the painter only if a painting
- isn't in `cache`."""
+ isn't in `cache`. The player comes with every combination of bionics."""
everyone = people()
said = personas()
wanted = list(only) or list(everyone)
@@ -360,6 +471,10 @@ def build(
changed = write(directory, files(who, painted, hash=hashes[who], appearance=appearance))
log(f"{who}: painted in {painted.seconds:.0f} s of FLUX, wrote {len(changed)} files")
done.append(painted)
+ if PLAYER_ID in wanted:
+ whole = next((p for p in done if p.who == PLAYER_ID), None)
+ player = (everyone[PLAYER_ID], hashes[PLAYER_ID], whole)
+ done += build_fitted(model, directory, *player, force=force, cut=cut, log=log)
if not only:
for path in stale(directory, everyone):
path.unlink()
diff --git a/server/simile/dolls/cli.py b/server/simile/dolls/cli.py
index 13ae7ffc..eb747f3e 100644
--- a/server/simile/dolls/cli.py
+++ b/server/simile/dolls/cli.py
@@ -18,14 +18,16 @@ def paint(
] = None,
) -> None:
"""Paint the figures in content/dolls/ whose Appearance changed, with FLUX on Apple
- Silicon (`uv sync --extra images`)."""
+ Silicon (`uv sync --extra images`), waiting while another image model runs."""
+ from functools import partial
+
from simile.dolls.build import build
- from simile.dolls.flux import Flux
+ from simile.dolls.flux import Flux, wait_for_gpu
from simile.dolls.measure import CLOSE
from simile.dolls.paint import NoPainter
try:
- done = build(Flux, only=who or (), force=force)
+ done = build(partial(Flux, wait_for_gpu), only=who or (), force=force)
except (KeyError, NoPainter) as error:
typer.echo(error.args[0], err=True)
raise typer.Exit(1) from None
diff --git a/server/simile/dolls/flux.py b/server/simile/dolls/flux.py
index 2e4c6ba9..6340c573 100644
--- a/server/simile/dolls/flux.py
+++ b/server/simile/dolls/flux.py
@@ -2,7 +2,10 @@
alone or, given pictures, in edit mode. Never imports MLX until it loads."""
import gc
+import os
+import subprocess
import tempfile
+import time
from collections.abc import Callable, Sequence
from importlib.util import find_spec
from pathlib import Path
@@ -12,6 +15,45 @@
from simile.dolls.paint import NoPainter
+OTHERS = ("mflux", "simile (papercraft|dolls)")
+"""Command lines of other image models, or builds that run them."""
+SHELLS = {"sh", "bash", "zsh", "fish", "sleep", "pgrep", "grep"}
+"""Programs whose command lines may name one without running it."""
+WAIT_S = 60
+
+
+def ours() -> set[str]:
+ """This process and those that started it, such as `uv run`."""
+ found, pid = set(), os.getpid()
+ while pid > 1 and str(pid) not in found:
+ found.add(str(pid))
+ parent = subprocess.run(["ps", "-o", "ppid=", "-p", str(pid)], capture_output=True)
+ pid = int(parent.stdout.strip() or 0)
+ return found
+
+
+def busy() -> list[str]:
+ """Other image models running on this Mac, and Ollama's: they share its memory."""
+ found, mine = [], ours()
+ for pattern in OTHERS:
+ run = subprocess.run(["pgrep", "-fl", pattern], capture_output=True, text=True)
+ for line in run.stdout.splitlines():
+ pid, _, command = line.partition(" ")
+ if pid not in mine and Path(command.split(" ", 1)[0]).name not in SHELLS:
+ found.append(line)
+ try:
+ ollama = subprocess.run(["ollama", "ps"], capture_output=True, text=True).stdout
+ except FileNotFoundError:
+ ollama = ""
+ return found + [f"ollama: {line}" for line in ollama.splitlines()[1:] if line.strip()]
+
+
+def wait_for_gpu(log: Callable[[str], None] = print) -> None:
+ """Waits until nothing else is using the model memory."""
+ while others := busy():
+ log(f"Waiting for {others}")
+ time.sleep(WAIT_S)
+
class Painter(Protocol):
"""Paints `prompt` at `size`, redrawing `images[0]` if there are any."""
@@ -98,7 +140,7 @@ def paint(
class FakePainter:
"""Draws a figure in the colours the prompt names, without a model: for tests and dry runs.
- An edit redraws `images[0]` a little darker."""
+ An edit redraws the figure in `images[0]` a little darker, with any bionic it asks for."""
BACKGROUND = (214, 214, 216)
@@ -119,10 +161,37 @@ def paint(
self.calls.append((prompt, seed, len(images)))
if images:
base = np.asarray(images[0].convert("RGB").resize(size), dtype=np.float64)
- return Image.fromarray(np.clip(base * 0.98, 0, 255).astype(np.uint8))
+ figure_only = np.where(np.all(base == self.BACKGROUND, axis=2), 1.0, 0.98)
+ darker = np.clip(base * figure_only[..., None], 0, 255).astype(np.uint8)
+ return fitted(Image.fromarray(darker), prompt)
return figure(size, named_colours(prompt))
+def fitted(painting: Image.Image, prompt: str) -> Image.Image:
+ """`painting` of `figure` with each bionic `prompt` asks for."""
+ from PIL import ImageDraw
+
+ from simile.dolls.bionics import WORDS
+ from simile.schema.player import Bionic
+
+ w, h = painting.size
+ top, tall, x = 0.08 * h, 0.84 * h, w / 2
+
+ def box(left: float, upper: float, right: float, lower: float) -> tuple[float, ...]:
+ return (x + left * tall, top + upper * tall, x + right * tall, top + lower * tall)
+
+ draw = ImageDraw.Draw(painting)
+ if WORDS[Bionic.HYDRAULIC_LEGS] in prompt:
+ for side in (-1, 1):
+ legs = sorted((side * 0.01, side * 0.1))
+ draw.rectangle(box(legs[0], 0.72, legs[1], 0.93), (150, 152, 158), (40, 26, 20), 3)
+ if WORDS[Bionic.DATAJACK] in prompt:
+ draw.ellipse(box(-0.13, 0.3, -0.09, 0.34), (60, 220, 60))
+ if WORDS[Bionic.INFRARED_EYE] in prompt:
+ draw.ellipse(box(0.04, 0.16, 0.08, 0.2), (222, 30, 30))
+ return painting
+
+
def named_colours(prompt: str) -> dict[str, str]:
"""The colours `words.described` names in `prompt`, as hex."""
import re
diff --git a/server/simile/dolls/measure.py b/server/simile/dolls/measure.py
index eda133f2..923ce578 100644
--- a/server/simile/dolls/measure.py
+++ b/server/simile/dolls/measure.py
@@ -151,11 +151,13 @@ def wanted(appearance: Appearance) -> dict[str, NDArray[np.float64]]:
}
-def core(figure: Image.Image, name: str) -> NDArray[np.bool_]:
+def core(
+ figure: Image.Image, name: str, cores: dict[str, tuple[float, float]] = CORES
+) -> NDArray[np.bool_]:
solid = opaque(figure)
rows = np.flatnonzero(solid.any(axis=1))
top, height = rows[0], rows[-1] - rows[0] + 1
- start, end = CORES[name]
+ start, end = cores[name]
out = np.zeros_like(solid)
out[top + int(start * height) : top + int(np.ceil(end * height))] = True
return out & solid
@@ -206,14 +208,20 @@ def follow(
return out
-def swatches(figure: Image.Image, appearance: Appearance, layout: str = "upright") -> dict:
+def swatches(
+ figure: Image.Image,
+ appearance: Appearance,
+ layout: str = "upright",
+ cores: dict[str, tuple[float, float]] = CORES,
+) -> dict:
"""Each colour as wanted, as painted, and how far apart they are, CIEDE2000: in a standing
figure from the middle of its band, in another pose from all of it."""
lab = cielab(np.asarray(figure.convert("RGB"), dtype=np.float64))
rows = bands(figure, layout)
out = {}
for name, target in wanted(appearance).items():
- where = core(figure, name) if layout == "upright" else opaque(figure) & (rows[name] >= 1)
+ upright = layout == "upright"
+ where = core(figure, name, cores) if upright else opaque(figure) & (rows[name] >= 1)
got, share = chosen(lab[where], target)
want = hex_of(from_cielab(target))
if np.isnan(got).any():
@@ -251,7 +259,9 @@ def kept(
return out
-def strays(figure: Image.Image, standing: Image.Image) -> float:
+def strays(
+ figure: Image.Image, standing: Image.Image, also: NDArray[np.float64] | None = None
+) -> float:
"""The share of `figure` painted in main colours the standing figure hasn't got, each over
`STRAY` from all of its: a garment or a limb that came out, or was pulled, another colour."""
mine = cielab(np.asarray(figure.convert("RGB"), dtype=np.float64)[opaque(figure)])
@@ -260,6 +270,8 @@ def strays(figure: Image.Image, standing: Image.Image) -> float:
return 0.0
centres, shares = main_colours(mine, 2 * KINDS)
known, _ = main_colours(theirs, 2 * KINDS)
+ if also is not None:
+ known = np.vstack([known, also])
far = np.array([delta_e_lab(known, c).min() for c in centres])
return round(float(shares[(far > STRAY) & (shares >= STRAY_LEAST)].sum()), 3)
@@ -320,7 +332,9 @@ def pulled(
return figure
-def face_marks(figure: Image.Image, appearance: Appearance) -> float:
+def face_marks(
+ figure: Image.Image, appearance: Appearance, spared: NDArray[np.bool_] | None = None
+) -> float:
"""The share of the face painted in patches far from the skin that aren't ink: the face is
the front of the thick of the skin in the top of the figure, and lines and specks thinner
than a hundredth of its height don't count."""
@@ -343,6 +357,8 @@ def face_marks(figure: Image.Image, appearance: Appearance) -> float:
x + int(FACE[0][0] * w) : x + int(FACE[0][1] * w),
] = True
odd = box & solid & (far >= FACE_FAR) & (lab[..., 0] >= INK)
+ if spared is not None:
+ odd &= ~spared
return float(opened(odd, max(1, round(0.01 * (bottom - top)))).sum() / max(box.sum(), 1))
diff --git a/server/simile/dolls/sprites.py b/server/simile/dolls/sprites.py
index 8e8e4dff..4ed9c3d3 100644
--- a/server/simile/dolls/sprites.py
+++ b/server/simile/dolls/sprites.py
@@ -170,12 +170,13 @@ def frames(
hands_up: Iterable[str] = (),
lifted: Mapping[str, str] | None = None,
seated: Iterable[str] = (),
+ reference: Image.Image | None = None,
) -> dict[str, Frame]:
"""Every cut-out figure at `PPM`: the standing one `HEIGHT_M` tall, and each other one by
the same factor over its `scale`, since FLUX may redraw a pose a little bigger or smaller.
A frame `lifted` to another is in the air: its feet go below its lowest row, so its head is
as high as the other's. A `seated` frame says where it sits."""
- stand = trimmed(figures[standing])
+ stand = trimmed(figures[standing] if reference is None else reference)
factor = HEIGHT_M * PPM / height(stand)
depth = HEAD * height(stand)
out = {}
diff --git a/server/tests/dolls/test_bionics.py b/server/tests/dolls/test_bionics.py
new file mode 100644
index 00000000..cd829436
--- /dev/null
+++ b/server/tests/dolls/test_bionics.py
@@ -0,0 +1,160 @@
+import numpy as np
+import pytest
+from PIL import Image, ImageDraw
+
+from simile.characters.palette import PLAYER
+from simile.dolls.bionics import (
+ COMBINATIONS,
+ FITTED,
+ changed,
+ edit_prompt,
+ figure_id,
+ laid,
+ missing,
+ patches,
+ shows,
+ spared,
+)
+from simile.dolls.figure import FRAMES, stand_prompt
+from simile.dolls.flux import FakePainter, figure, fitted, named_colours
+from simile.dolls.measure import face_marks
+from simile.schema.player import Bionic, Body
+
+from .keyed import keyed
+
+LEGS, DATAJACK, EYE = FITTED
+SIZE = (300, 400)
+
+
+def whole() -> Image.Image:
+ return figure(SIZE, named_colours(stand_prompt(PLAYER, "player")))
+
+
+def with_parts(*parts: Bionic) -> Image.Image:
+ painting = whole()
+ for part in parts:
+ painting = fitted(painting, edit_prompt("stand", part, PLAYER))
+ return keyed(painting)
+
+
+def test_every_combination_once_fewest_first() -> None:
+ assert len(COMBINATIONS) == 7 == len(set(COMBINATIONS))
+ assert [len(c) for c in COMBINATIONS] == [1, 1, 1, 2, 2, 2, 3]
+ assert all(list(c) == sorted(c, key=FITTED.index) for c in COMBINATIONS)
+
+
+@pytest.mark.parametrize(
+ ("fitted_", "id_"),
+ [
+ ((), "player"),
+ ((LEGS,), "player_legs"),
+ ((EYE, LEGS), "player_legs_eye"),
+ ((EYE, DATAJACK, LEGS), "player_legs_datajack_eye"),
+ ],
+)
+def test_names_the_figure_for_whatever_is_fitted(fitted_: tuple, id_: str) -> None:
+ assert figure_id(fitted_) == id_
+
+
+def test_a_body_names_its_figure_by_its_bionics_whichever_eye() -> None:
+ left = Body(left_eye=EYE, vertebra=DATAJACK)
+ right = Body(right_eye=EYE, vertebra=DATAJACK, left_kidney="sold")
+
+ assert figure_id(left.bionics) == figure_id(right.bionics) == "player_datajack_eye"
+
+
+def test_the_eye_isnt_seen_from_behind() -> None:
+ assert [f for f in FRAMES if not shows(EYE, f)] == ["climb"]
+ assert all(shows(LEGS, f) and shows(DATAJACK, f) for f in FRAMES)
+
+
+def test_each_part_is_asked_for_alone_over_the_whole_player() -> None:
+ legs, eye = edit_prompt("run", LEGS, PLAYER), edit_prompt("run", EYE, PLAYER)
+
+ assert "now with both legs replaced" in legs and "eye nearer the ear" not in legs
+ assert " shorts and " in legs and " trousers and " not in legs
+ assert "now with the eye nearer the ear" in eye and " trousers and " in eye
+ assert "one bright red bionic eye" in eye and "simple dot eyes" in legs
+
+
+def test_climbing_asks_for_no_face() -> None:
+ climb = edit_prompt("climb", DATAJACK, PLAYER)
+
+ assert "datajack" in climb and "eye" not in climb
+
+
+def test_finds_each_part_painted() -> None:
+ assert missing(with_parts(), FITTED, "stand") == ["legs", "datajack", "eye"]
+ assert missing(with_parts(LEGS, DATAJACK, EYE), FITTED, "stand") == []
+ assert missing(with_parts(DATAJACK), FITTED, "climb") == ["legs"]
+
+
+def test_both_eyes_painted_red_is_one_too_many() -> None:
+ painting = with_parts(EYE)
+ w, h = SIZE
+ ImageDraw.Draw(painting).ellipse((0.47 * w, 0.21 * h, 0.5 * w, 0.24 * h), (222, 30, 30))
+
+ assert missing(painting, (EYE,), "stand") == ["two eyes"]
+
+
+def test_an_eye_wider_than_the_walks_is_too_big() -> None:
+ painting = with_parts(EYE)
+ w, h = SIZE
+ ImageDraw.Draw(painting).ellipse((0.5 * w, 0.15 * h, 0.62 * w, 0.24 * h), (222, 30, 30))
+
+ assert missing(with_parts(EYE), (EYE,), "stand") == []
+ assert missing(painting, (EYE,), "stand") == ["eye too big"]
+
+
+def test_the_face_check_spares_the_eye_and_nothing_else() -> None:
+ eyed = with_parts(EYE)
+ w, h = SIZE
+ marked = eyed.copy()
+ ImageDraw.Draw(marked).ellipse((0.53 * w, 0.27 * h, 0.6 * w, 0.32 * h), (139, 155, 180))
+
+ assert face_marks(eyed, PLAYER, spared(eyed)) == 0
+ assert face_marks(marked, PLAYER, spared(marked)) > 0
+
+
+def test_counts_patches_biggest_first() -> None:
+ mask = np.zeros((10, 10), dtype=bool)
+ mask[1:3, 1:3] = True
+ mask[5:9, 5:9] = True
+
+ assert [int(p.sum()) for p in patches(mask)] == [16, 4]
+
+
+def test_a_part_is_where_painting_it_changed_the_frame_near_it() -> None:
+ player = with_parts()
+ eye = with_parts(EYE)
+ w, h = SIZE
+ ImageDraw.Draw(eye).rectangle((0.45 * w, 0.8 * h, 0.55 * w, 0.85 * h), (139, 155, 180))
+
+ where = changed(player, eye, EYE)
+
+ lens = np.argwhere(np.asarray(eye.convert("RGB")) == (222, 30, 30))
+ assert where[lens[:, 0], lens[:, 1]].all()
+ assert not where[int(0.82 * h), int(0.5 * w)]
+ assert where.sum() < 0.05 * where.size
+
+
+def test_parts_are_laid_over_the_player_and_nothing_else_changes() -> None:
+ player = with_parts()
+ legs, eye = with_parts(LEGS), with_parts(EYE)
+ w, h = SIZE
+
+ both = laid(player, [(legs, changed(player, legs, LEGS)), (eye, changed(player, eye, EYE))])
+
+ assert missing(both, (LEGS, EYE), "stand") == []
+ before, after = np.asarray(player), np.asarray(both)
+ assert (before[: int(0.12 * h)] == after[: int(0.12 * h)]).all()
+ assert (before[:, : int(0.2 * w)] == after[:, : int(0.2 * w)]).all()
+
+
+def test_the_fake_painter_paints_what_an_edit_asks_for() -> None:
+ painter = FakePainter()
+ base = painter.paint(stand_prompt(PLAYER, "player"), SIZE, 1)
+
+ edited = painter.paint(edit_prompt("stand", EYE, PLAYER), SIZE, 1, [base])
+
+ assert missing(keyed(edited), FITTED, "stand") == ["legs", "datajack"]
diff --git a/server/tests/dolls/test_build.py b/server/tests/dolls/test_build.py
index 8a6c2cdc..4f99a19c 100644
--- a/server/tests/dolls/test_build.py
+++ b/server/tests/dolls/test_build.py
@@ -6,10 +6,12 @@
import pytest
from PIL import Image, ImageDraw
-from simile.dolls import build
+from simile.dolls import bionics, build
+from simile.dolls.bionics import COMBINATIONS, WORDS, figure_id
from simile.dolls.figure import FRAMES, POSES
from simile.dolls.flux import FakePainter, figure
from simile.dolls.measure import MARKS
+from simile.schema.player import Bionic
from .keyed import keyed
@@ -54,6 +56,15 @@ def paint(
return crop.resize(size)
+class OneEyed(FakePainter):
+ """Never paints the bionic eye."""
+
+ def paint(
+ self, prompt: str, size: tuple[int, int], seed: int, images: Sequence[Image.Image] = ()
+ ) -> Image.Image:
+ return super().paint(prompt.replace(WORDS[Bionic.INFRARED_EYE], ""), size, seed, images)
+
+
class FacePaint(FakePainter):
"""Paints a blue-grey patch on the cheek of every pose."""
@@ -74,8 +85,12 @@ def stamps(directory: Path) -> dict[str, int]:
return {path.name: path.stat().st_mtime_ns for path in directory.iterdir()}
+LEGS, DATAJACK, EYE = bionics.FITTED
+
+
@pytest.fixture
-def two(monkeypatch: pytest.MonkeyPatch) -> dict:
+def every(monkeypatch: pytest.MonkeyPatch) -> dict:
+ """Odalys and the player, with every combination of bionics."""
monkeypatch.setattr(build, "SIZE", (192, 256))
everyone = build.people()
chosen = {who: everyone[who] for who in ("odalys", "player")}
@@ -83,6 +98,20 @@ def two(monkeypatch: pytest.MonkeyPatch) -> dict:
return chosen
+@pytest.fixture
+def fitted(every: dict, monkeypatch: pytest.MonkeyPatch) -> dict:
+ """Odalys and the player, with the datajack, and with the eye too."""
+ monkeypatch.setattr(build, "COMBINATIONS", ((DATAJACK,), (DATAJACK, EYE)))
+ return every
+
+
+@pytest.fixture
+def two(every: dict, monkeypatch: pytest.MonkeyPatch) -> dict:
+ """Odalys and the player, without bionics."""
+ monkeypatch.setattr(build, "COMBINATIONS", ())
+ return every
+
+
def test_paints_each_figure_and_a_rerun_rewrites_nothing(tmp_path: Path, two: dict) -> None:
painters = Painters()
@@ -232,3 +261,76 @@ def test_seats_are_measured_again_from_the_atlas_without_painting(
assert sorted(p.name for p in rewritten) == ["odalys.json", "player.json"]
assert {p.name: p.read_bytes() for p in tmp_path.iterdir()} == built
assert build.reseat(tmp_path) == []
+
+
+def test_paints_the_player_with_each_combination_of_bionics(tmp_path: Path, every: dict) -> None:
+ painters = Painters()
+
+ done = made(painters, tmp_path, only=["player"], cache=None)
+
+ assert [p.who for p in done] == ["player", "player_legs", "player_datajack", "player_eye"]
+ parts = sum(bionics.shows(b, frame) for b in bionics.FITTED for frame in FRAMES)
+ assert len(painters.fake.calls) == len(FRAMES) + parts
+ player = build.fingerprint("player", every["player"])
+ for combination in COMBINATIONS:
+ who = figure_id(combination)
+ manifest = json.loads((tmp_path / f"{who}.json").read_text())
+ assert manifest["fitted"] == [b.value for b in combination]
+ assert manifest["appearance"] == every["player"].model_dump(mode="json")
+ assert manifest["poses"] == POSES and set(manifest["frames"]) == set(FRAMES)
+ assert manifest["painted"] == f"{who}.webp"
+ assert manifest["hash"] == build.fitted_fingerprint(combination, player, every["player"])
+ for painted in done[1:]:
+ assert all(not tries[-1].missing for tries in painted.tries.values()), painted.who
+
+
+def test_each_part_is_painted_once_over_the_player_and_laid_over_it(
+ tmp_path: Path, fitted: dict
+) -> None:
+ painters = Painters()
+
+ made(painters, tmp_path, only=["player"], cache=None)
+
+ edits = [prompt for prompt, _, images in painters.fake.calls if images]
+ eye = [prompt for prompt in edits if WORDS[EYE] in prompt]
+ assert len(eye) == len(FRAMES) - 1
+ assert not any(WORDS[DATAJACK] in prompt for prompt in eye)
+ both = json.loads((tmp_path / "player_datajack_eye.json").read_text())
+ datajack = json.loads((tmp_path / "player_datajack.json").read_text())
+ climb, without_eye = (m["frames"]["climb"] for m in (both, datajack))
+ assert climb["feet"] == without_eye["feet"]
+ assert climb["frame"][2:] == without_eye["frame"][2:]
+
+
+def test_the_player_with_bionics_is_painted_again_only_when_it_changes(
+ tmp_path: Path, fitted: dict, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ painters = Painters()
+ made(painters, tmp_path, only=["player"], cache=None)
+ before, calls = stamps(tmp_path), len(painters.fake.calls)
+
+ made(painters, tmp_path, only=["player"], cache=None)
+ assert len(painters.fake.calls) == calls and stamps(tmp_path) == before
+
+ monkeypatch.setitem(build.REROLLS, "player_datajack/stand", 1)
+ made(painters, tmp_path, only=["player"], cache=None)
+
+ changed = {name for name, stamp in stamps(tmp_path).items() if stamp != before[name]}
+ assert {"player_datajack.json", "player_datajack_eye.json"} <= changed
+ assert not changed & {"player.json", "player.webp", "odalys.json"}
+
+
+def test_a_bionic_that_isnt_painted_is_painted_again(tmp_path: Path, fitted: dict) -> None:
+ done = made(Painters(OneEyed()), tmp_path, only=["player"], cache=None)
+
+ eye = next(p for p in done if p.who == "player_eye")
+ assert len(eye.tries["stand"]) == build.TRIES["fitted"]
+ assert eye.tries["stand"][-1].missing == ["eye"]
+ assert "climb" not in eye.tries
+
+
+def test_the_player_with_bionics_is_kept_with_the_player(tmp_path: Path, fitted: dict) -> None:
+ made(Painters(), tmp_path, cache=None)
+
+ names = {p.name for p in tmp_path.iterdir()}
+ assert {f"{figure_id(c)}.webp" for c in build.COMBINATIONS} <= names
diff --git a/server/tests/dolls/test_content.py b/server/tests/dolls/test_content.py
index 02fb3340..e2b19e31 100644
--- a/server/tests/dolls/test_content.py
+++ b/server/tests/dolls/test_content.py
@@ -5,9 +5,18 @@
import pytest
from PIL import Image
-from simile.dolls.build import CONTENT, MISSES, fingerprint, people, personas
+from simile.dolls import bionics
+from simile.dolls.bionics import COMBINATIONS, SHORTS, figure_id, missing, spared
+from simile.dolls.build import (
+ CONTENT,
+ MISSES,
+ fingerprint,
+ fitted_fingerprint,
+ people,
+ personas,
+)
from simile.dolls.figure import EDITS, FRAMES, POSES
-from simile.dolls.measure import CLOSE, MARKS, face_marks, hidden, swatches
+from simile.dolls.measure import CLOSE, CORES, MARKS, face_marks, hidden, swatches
from simile.dolls.sprites import (
HEAD,
HEIGHT_M,
@@ -19,9 +28,20 @@
scale,
seat,
)
+from simile.schema import PLAYER_ID
+from simile.schema.player import Bionic
BUDGET = 3 * 1024 * 1024
EVERYONE = people()
+FITTED = {figure_id(c): c for c in COMBINATIONS}
+FIGURES = EVERYONE | dict.fromkeys(FITTED, EVERYONE[PLAYER_ID])
+"""Every resident, and the player with each combination of bionics."""
+SAME_SIZE = 0.03
+"""How much bigger or smaller the player's head may be drawn with bionics, once scaled."""
+SHOWN = 0.7
+"""The share of `bionics.LEAST` a part must cover once the atlas has shrunk it, lossily."""
+EYE_SIZES = 2
+"""How many atlas pixels wider the eye's lens may be in one frame than another."""
FEET_PX = 2.5
"""How far a frame's feet may be from under the middle of its head and its lowest row, after
the atlas's lossy WebP."""
@@ -65,7 +85,7 @@ def test_every_resident_and_the_player_has_a_current_figure(who: str) -> None:
assert found["ppm"] == PPM and found["height"] == pytest.approx(HEIGHT_M * PPM, abs=0.1)
-@pytest.mark.parametrize("who", EVERYONE)
+@pytest.mark.parametrize("who", FIGURES)
def test_each_frame_stands_under_the_middle_of_its_head(who: str) -> None:
depth = HEAD * HEIGHT_M * PPM
hands_up = [f for f, pose in EDITS.items() if pose.bands == "climb"]
@@ -85,17 +105,19 @@ def test_each_frame_stands_under_the_middle_of_its_head(who: str) -> None:
assert feet_x == pytest.approx(middle, abs=FEET_PX), name
-@pytest.mark.parametrize("who", EVERYONE)
+@pytest.mark.parametrize("who", FIGURES)
def test_standing_figures_keep_the_appearances_colours(who: str) -> None:
- found = swatches(frame(who, "stand"), EVERYONE[who])
+ legs = Bionic.HYDRAULIC_LEGS in FITTED.get(who, ())
+ cores = CORES | {"bottom": SHORTS} if legs else CORES
+ found = swatches(frame(who, "stand"), FIGURES[who], cores=cores)
for name, swatch in found.items():
- if name in hidden(EVERYONE[who]) or (who, "stand", name) in MISSES:
+ if name in hidden(FIGURES[who]) or (who, "stand", name) in MISSES:
continue
assert swatch.delta_e < FROM_APPEARANCE, f"{name}: {swatch}"
-@pytest.mark.parametrize("who", EVERYONE)
+@pytest.mark.parametrize("who", FIGURES)
def test_every_frame_is_drawn_to_the_standing_figures_scale(who: str) -> None:
stand = frame(who, "stand")
for name, pose in EDITS.items():
@@ -103,13 +125,59 @@ def test_every_frame_is_drawn_to_the_standing_figures_scale(who: str) -> None:
assert drawn == pytest.approx(1, abs=SCALE_OFF), name
-@pytest.mark.parametrize("who", EVERYONE)
+@pytest.mark.parametrize("who", FIGURES)
def test_no_face_is_painted_over(who: str) -> None:
+ eye = Bionic.INFRARED_EYE in FITTED.get(who, ())
for name in ["stand", *(name for name, pose in EDITS.items() if pose.face)]:
- assert face_marks(frame(who, name), EVERYONE[who]) <= MARKS, name
+ picture = frame(who, name)
+ assert face_marks(picture, FIGURES[who], spared(picture) if eye else None) <= MARKS, name
-@pytest.mark.parametrize("who", EVERYONE)
+@pytest.mark.parametrize("who", FITTED)
+def test_the_player_has_a_current_figure_for_each_combination_of_bionics(who: str) -> None:
+ found = manifest(who)
+
+ player = fingerprint(PLAYER_ID, EVERYONE[PLAYER_ID])
+ assert found["hash"] == fitted_fingerprint(FITTED[who], player, EVERYONE[PLAYER_ID])
+ assert found["fitted"] == [b.value for b in FITTED[who]]
+ assert found["appearance"] == EVERYONE[PLAYER_ID].model_dump(mode="json")
+ assert found["poses"] == POSES
+ assert set(found["frames"]) == set(FRAMES)
+ assert found["ppm"] == PPM and found["height"] == pytest.approx(HEIGHT_M * PPM, abs=0.1)
+
+
+@pytest.mark.parametrize("who", FITTED)
+def test_every_frame_shows_the_bionics_fitted(who: str) -> None:
+ for name in FRAMES:
+ assert missing(frame(who, name), FITTED[who], name, SHOWN) == [], name
+
+
+def test_the_whole_player_shows_no_bionics() -> None:
+ for name in FRAMES:
+ found = missing(frame(PLAYER_ID, name), bionics.FITTED, name, SHOWN)
+
+ assert found == [bionics.NAMES[b] for b in bionics.FITTED if bionics.shows(b, name)], name
+
+
+@pytest.mark.parametrize("who", [who for who, parts in FITTED.items() if bionics.EYE in parts])
+def test_the_eye_is_one_size_in_every_frame(who: str) -> None:
+ def width(name: str) -> int:
+ lens = bionics.patches(bionics.parts(frame(who, name))[bionics.EYE])[0]
+ return int(np.ptp(np.flatnonzero(lens.any(axis=0)))) + 1
+
+ widths = {name: width(name) for name in FRAMES if bionics.shows(bionics.EYE, name)}
+
+ assert max(widths.values()) - min(widths.values()) <= EYE_SIZES, widths
+
+
+@pytest.mark.parametrize("who", FITTED)
+def test_the_player_is_the_same_size_whatever_is_fitted(who: str) -> None:
+ drawn = scale(frame(who, "stand"), frame(PLAYER_ID, "stand"))
+
+ assert drawn == pytest.approx(1, abs=SAME_SIZE)
+
+
+@pytest.mark.parametrize("who", FIGURES)
def test_the_head_stays_level_through_the_run(who: str) -> None:
def crown(name: str) -> float:
solid = np.asarray(frame(who, name).getchannel("A")) >= 128
@@ -120,7 +188,7 @@ def crown(name: str) -> float:
assert crown(name) >= crown(pose.lifted) - FEET_PX, name
-@pytest.mark.parametrize("who", EVERYONE)
+@pytest.mark.parametrize("who", FIGURES)
def test_sitting_sits_on_its_thighs_with_its_feet_below(who: str) -> None:
for name, pose in EDITS.items():
if pose.seated:
@@ -146,7 +214,7 @@ def thigh_line(solid: np.ndarray) -> int:
return int(edge[best[0] : best[1]].max() + 1)
-@pytest.mark.parametrize("who", EVERYONE)
+@pytest.mark.parametrize("who", FIGURES)
def test_sitting_sits_on_the_bottom_of_its_thighs_as_painted(who: str) -> None:
for name, pose in EDITS.items():
if pose.seated:
@@ -158,7 +226,7 @@ def test_sitting_sits_on_the_bottom_of_its_thighs_as_painted(who: str) -> None:
assert abs(entry["seat"][1] - thigh_line(solid)) / PPM <= ON_SEAT, name
-@pytest.mark.parametrize("who", EVERYONE)
+@pytest.mark.parametrize("who", FIGURES)
def test_figures_are_opaque_inside_their_outline(who: str) -> None:
alpha = np.asarray(atlas(who).getchannel("A")).astype(int)
clear = alpha < 8
diff --git a/server/tests/dolls/test_flux.py b/server/tests/dolls/test_flux.py
index 542f1550..5c0a6362 100644
--- a/server/tests/dolls/test_flux.py
+++ b/server/tests/dolls/test_flux.py
@@ -1,6 +1,9 @@
+import os
+
import pytest
from simile.characters.palette import PLAYER
+from simile.dolls import flux
from simile.dolls.figure import edit_prompt, stand_prompt
from simile.dolls.flux import Flux
@@ -20,3 +23,33 @@ def test_paints_from_text_then_edits_the_painting() -> None:
assert stand.size == sit.size == (256, 352)
assert stand.tobytes() != sit.tobytes()
+
+
+class Ran:
+ def __init__(self, stdout: str) -> None:
+ self.stdout = stdout
+
+
+def test_waits_for_other_image_models_but_not_for_itself(monkeypatch: pytest.MonkeyPatch) -> None:
+ def run(command: list[str], **kwargs: object) -> Ran:
+ if command[0] == "ollama":
+ return Ran("NAME ID SIZE\n")
+ if "mflux" in command:
+ return Ran("41 /bin/zsh -c pgrep mflux\n")
+ return Ran("7 uv run simile dolls paint\n8 python simile papercraft\n")
+
+ monkeypatch.setattr(flux.subprocess, "run", run)
+ monkeypatch.setattr(flux, "ours", lambda: {"7"})
+ naps: list[float] = []
+ monkeypatch.setattr(flux.time, "sleep", naps.append)
+
+ assert flux.busy() == ["8 python simile papercraft"]
+
+ calls = iter([["8 python simile papercraft"], []])
+ monkeypatch.setattr(flux, "busy", lambda: next(calls))
+ flux.wait_for_gpu(log=lambda _: None)
+ assert naps == [flux.WAIT_S]
+
+
+def test_this_process_and_what_started_it_are_its_own() -> None:
+ assert {str(os.getpid()), str(os.getppid())} <= flux.ours()
diff --git a/server/tests/dolls/test_sprites.py b/server/tests/dolls/test_sprites.py
index 915a8a37..8b2d25cd 100644
--- a/server/tests/dolls/test_sprites.py
+++ b/server/tests/dolls/test_sprites.py
@@ -85,6 +85,17 @@ def test_frames_are_scaled_to_the_street_and_keep_the_head_one_size() -> None:
assert frame.feet[0] == pytest.approx(head(frame.image, 0.25 * HEIGHT_M * PPM)[0])
+def test_frames_scaled_against_another_figure_keep_its_head_size() -> None:
+ whole = frames({"stand": person()})
+ made = frames({"stand": person(scale=1.2), "stride": person(lean=0.15)}, reference=person())
+
+ depth = 0.25 * HEIGHT_M * PPM
+ for frame in made.values():
+ assert head(frame.image, depth)[1] == pytest.approx(
+ head(whole["stand"].image, depth)[1], abs=2
+ )
+
+
def test_packs_every_frame_apart_into_one_atlas() -> None:
images = {
f"f{i}": Image.new("RGBA", (90 + 7 * i, 160 - 9 * i), (9, 9, 9, 255)) for i in range(12)