js-dojo

4 live reclass setprototypeof

GOAL

Swap the CLASS of a live object without swapping the object: implement `updatePlayerType(player, 'warrior')` so the very same instance — still held by game.players[], the camera, the hud — starts behaving as a Warrior while keeping every bit of its per-instance state.

CONCEPT

Both `instanceof` and method lookup follow the instance's [[Prototype]] link, and that link can be rewritten: `Object.setPrototypeOf(obj, OtherCtor.prototype)`. Own properties (hits, selected, settings) do not go anywhere — they sit on the object itself. Only the delegation target moves. That is how an object can "re-class" halfway through a match while every outside reference to it keeps working. (Cost note: engines de-optimise objects whose prototype changes — fine for an occasional player-triggered class change, wrong inside a hot loop.)

HINT

Find the new prototype in the registry, swap it in with Object.setPrototypeOf, throw for class names the registry has never heard of (the `engine` does the same, "Error 17"), and return the SAME reference.

MIRRORS

Entity systems and widget libraries pull this exact trick: when a player picks a new class, or a control is re-typed at runtime, the library does if (Object.setPrototypeOf) { Object.setPrototypeOf(entity, registry[newType].prototype); } rather than allocate a replacement — the scene graph, input handlers and hud all keep pointing at the object they already know.

Run

node 04-live-reclass-setprototypeof.cjs

Source

// ---------------------------------------------------------------------
// 03-classes-desugared / 04-live-reclass-setprototypeof.cjs
//
// GOAL: Swap the CLASS of a live object without swapping the object:
//       implement `updatePlayerType(player, 'warrior')` so the very
//       same instance — still held by game.players[], the camera, the
//       hud — starts behaving as a Warrior while keeping every bit of
//       its per-instance state.
//
// CONCEPT: Both `instanceof` and method lookup follow the instance's
//          [[Prototype]] link, and that link can be rewritten:
//          `Object.setPrototypeOf(obj, OtherCtor.prototype)`. Own
//          properties (hits, selected, settings) do not go anywhere —
//          they sit on the object itself. Only the delegation target
//          moves. That is how an object can "re-class" halfway through
//          a match while every outside reference to it keeps working.
//          (Cost note: engines de-optimise objects whose prototype
//          changes — fine for an occasional player-triggered class
//          change, wrong inside a hot loop.)
//
// HINT: Find the new prototype in the registry, swap it in with
//       Object.setPrototypeOf, throw for class names the registry has
//       never heard of (the `engine` does the same, "Error 17"), and
//       return the SAME reference.
//
// MIRRORS: Entity systems and widget libraries pull this exact trick:
//          when a player picks a new class, or a control is re-typed at
//          runtime, the library does if (Object.setPrototypeOf) {
//          Object.setPrototypeOf(entity, registry[newType].prototype);
//          } rather than allocate a replacement — the scene graph,
//          input handlers and hud all keep pointing at the object they
//          already know.
//
// Run: node 04-live-reclass-setprototypeof.cjs
// ---------------------------------------------------------------------
'use strict';
const assert = require('node:assert');

// -- Setup: a mini class registry, engine-style (do not edit) ---------
const playerTypes = {};

class Player {
  constructor(game, settings) {
    this.game = game;
    this.settings = settings;
    this.name = settings.name;
    this.hits = settings.hits.slice();
    this.selected = false;
  }
  select() { this.selected = true; }
}

class Archer extends Player {
  render() { return `arrows[${this.hits.join('->')}]`; }
}
class Warrior extends Player {
  render() { return `swings[${this.hits.join('|')}]`; }
  shieldBash() { return true; }   // warrior-only API, must appear after the switch
}
playerTypes.archer = Archer;
playerTypes.warrior = Warrior;

// -- TODO 1: implement the class switch -------------------------------
// Rules (all enforced below):
//   1. Unknown class name → throw an Error whose message contains 'unknown'
//      (mirror of the engine's "Error 17: requested player class does not exist").
// 2. The SAME object must come back — never `new` a replacement; the
// game's
//      players array (and every camera/hud back-reference) holds this ref.
//   3. After the call the instance must delegate to the NEW class's prototype.
//   4. All own state (hits, selected, settings, game) must survive untouched.
function updatePlayerType(player, newTypeName) {
  // ...your code here (it is 3-5 lines)...
}

// -- Checks (do not edit) ---------------------------------------------
const game = { players: [] };
const p = new Archer(game, { name: 'knight', hits: [12, 7, 30] });
game.players.push(p);
p.select();                                  // per-instance state to preserve

assert.strictEqual(p.render(), 'arrows[12->7->30]', 'sanity: starts as an archer');
assert.ok(p instanceof Archer, 'sanity: starts as Archer');

const returned = updatePlayerType(p, 'warrior');

assert.ok(returned instanceof Warrior,
  'TODO: after the switch the object should BE a Warrior — do not build a new ' +
  'object, swap the [[Prototype]] of the existing one (Object.setPrototypeOf)');
assert.strictEqual(returned, p,
  'TODO: must return the SAME reference — game.players[0], the camera and the ' +
  'hud all point at this exact object');
assert.strictEqual(game.players[0], p,
  'TODO: the game never noticed: its array still holds the original reference');

assert.ok(!(p instanceof Archer),
  'TODO: Archer.prototype is no longer in the chain — instanceof is a LIVE walk ' +
  'of [[Prototype]] links, not a birth certificate');
assert.ok(p instanceof Player,
  'TODO: still a Player: Warrior.prototype itself delegates to Player.prototype');

assert.strictEqual(p.render(), 'swings[12|7|30]',
  'TODO: method lookup now resolves through Warrior.prototype — same hits, ' +
  'new behavior');
assert.strictEqual(typeof p.shieldBash, 'function',
  'TODO: warrior-only API appears: lookup walks the NEW chain');

assert.deepStrictEqual(p.hits, [12, 7, 30],
  'TODO: own state untouched — setPrototypeOf changes the delegation target, ' +
  'never the own properties');
assert.strictEqual(p.selected, true,
  'TODO: selection survived the class switch (the engine keeps own props on re-class)');
assert.strictEqual(p.name, 'knight', 'TODO: name survived');

assert.throws(() => updatePlayerType(p, 'necromancer'), /unknown/i,
  'TODO: unknown class must throw (engine Error 17: "requested player class does ' +
  'not exist") — silently keeping the old prototype hides real bugs');

console.log('OK — 04-live-reclass-setprototypeof: one object, new class, zero broken references.');

Solution